├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── poll ├── app │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── CreatePoll.tsx │ │ ├── PollClient.ts │ │ ├── PollForm.tsx │ │ ├── PollInfo.tsx │ │ ├── PollSummary.tsx │ │ ├── Routes.ts │ │ ├── Summary.tsx │ │ ├── Vote.tsx │ │ ├── VoteForm.tsx │ │ ├── assets │ │ │ └── raleway-regular.woff │ │ └── index.tsx │ └── tsconfig.json ├── infra │ ├── package.json │ └── serverless.yml ├── model │ ├── package.json │ ├── src │ │ └── poll.ts │ └── tsconfig.json ├── service │ ├── package.json │ ├── serverless.yml │ ├── src │ │ ├── creator.ts │ │ ├── persister.ts │ │ ├── poll.ts │ │ ├── response.ts │ │ └── result-model.ts │ └── tsconfig.json └── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | .env.local 75 | .env.development.local 76 | .env.test.local 77 | .env.production.local 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Next.js build output 83 | .next 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # VS Code 111 | .vscode 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Insify 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deploying Typescript Lambda functions with Serverless in a monorepo 2 | 3 | ## The application 4 | * A simple UI that you to create a poll with a topic and 2 to 5 options. 5 | * When the poll has been created, it shows 2 links. If you open the first link, you can vote on the poll. This link should be shared with people you want to participate. By the second link, you can see the results of the poll. 6 | * The server side has 4 Lambda functions exposed via API Gateway endpoints (create poll, get poll details, vote and see the summary). Polls and their results are stored in a DynamoDB table. 7 | 8 | The repository contains a description of the infrastructure, the backend service, the frontend application and some shared code. 9 | 10 | ## How to run 11 | Prerequisites: 12 | * an AWS account 13 | * nodejs 12 14 | * React 15 | * yarn 16 | * serverless 17 | 18 | ``` 19 | yarn 20 | cd poll/infra && sls deploy # Will create all necessay infrastructure. 21 | cd poll/service && sls deploy # Will deploy Lambda functions 22 | cd poll/app && yarn start 23 | ``` 24 | 25 | **Warning:** Server-side from the master branch will not work! Please switch to any branch to have a working example. 26 | 27 | ## Branches 28 | 29 | ### Experiment 1 30 | Disable hoisiting in Yarn Workspaces. All external dependencies will be replicated to `node_modules` of the service. Additionally, copy internal dependencies (dependencies to other modules from this repository) with Yalc. 31 | 32 | ### Experiment 2 33 | Use `serverless-plugin-monorepo` instead of using nohoist. It creates symlinks to all the modules that Lambda functions use. To make it work, replace `serverless-plugin-typescript` with calling Typescript compiler manually. 34 | 35 | ### Experiment 3 36 | Use `serverless-plugin-webpack` to transpile and package Lambda functions. Provide Babel and Webpack configuration, and make sure the gigantic `aws-sdk` dependency doesn't creep in. 37 | 38 | ### Experiment 4 39 | Use `serverless-plugin-optimize` that packages Lambda functions and minimizes their size. 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-sls-monorepo-example", 3 | "version": "1.0.0", 4 | "description": "An example of a project built on Serverless and React and organized as a monorepo", 5 | "main": "index.js", 6 | "repository": "https://github.com/alexeyu/react-sls-monorepo-example.git", 7 | "author": "Oleksii Ustenko", 8 | "license": "MIT", 9 | "private": true, 10 | "workspaces": { 11 | "packages": [ 12 | "poll/*" 13 | ] 14 | }, 15 | "devDependencies": { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /poll/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test", 9 | "eject": "react-scripts eject" 10 | }, 11 | "eslintConfig": { 12 | "extends": "react-app" 13 | }, 14 | "browserslist": { 15 | "production": [ 16 | ">0.2%", 17 | "not dead", 18 | "not op_mini all" 19 | ], 20 | "development": [ 21 | "last 1 chrome version", 22 | "last 1 firefox version", 23 | "last 1 safari version" 24 | ] 25 | }, 26 | "dependencies": { 27 | "@poll/model": "1.0.0", 28 | "axios": "^0.19.2", 29 | "react": "^16.13.1", 30 | "react-dom": "^16.13.1", 31 | "react-router-dom": "^5.2.0", 32 | "react-skeleton-css": "^1.1.0" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^24.0.0", 36 | "@types/node": "^12.0.0", 37 | "@types/react": "^16.9.0", 38 | "@types/react-dom": "^16.9.0", 39 | "@types/react-router-dom": "^5.1.5", 40 | "react-scripts": "3.4.1", 41 | "typescript": "^4.0.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /poll/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insify/serverless-typescript-monorepo-example/ae5bdbd21399b26e54687328a85ca0f71d79fe72/poll/app/public/favicon.ico -------------------------------------------------------------------------------- /poll/app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | Poll App 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /poll/app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Poll App", 3 | "name": "Poll App", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /poll/app/src/App.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Raleway; 3 | src: url("./assets/raleway-regular.woff") format("woff"); 4 | } 5 | 6 | .App { 7 | margin-top: 50px; 8 | padding: 20px 20px 5px 20px; 9 | background-color: whitesmoke; 10 | } 11 | 12 | .container { 13 | max-width: 600px; 14 | } 15 | 16 | label { 17 | font-weight: 400; 18 | } 19 | 20 | .row { 21 | display: flex; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .question { 26 | font-weight: bold; 27 | } -------------------------------------------------------------------------------- /poll/app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./App.css"; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | import CreatePoll from "./CreatePoll"; 5 | import Vote from "./Vote"; 6 | import Summary from "./Summary"; 7 | import { SUMMARY, VOTE } from "./Routes"; 8 | 9 | const App = () => { 10 | return ( 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 |
19 |
); 20 | }; 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /poll/app/src/CreatePoll.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import "./App.css"; 3 | import { createPoll } from "./PollClient"; 4 | import { Poll, emptyPoll } from "@poll/model"; 5 | import PollForm from "./PollForm"; 6 | import PollInfo from "./PollInfo"; 7 | import { SUMMARY, VOTE } from "./Routes"; 8 | 9 | const CreatePoll = () => { 10 | const [pollId, setPollId] = useState(""); 11 | 12 | const create = (qa: Poll) => { 13 | createPoll({ ...qa }) 14 | .then(setPollId) 15 | .catch(console.error); 16 | }; 17 | 18 | return ( 19 |
20 | {pollId.length === 0 ? ( 21 | 22 | ) : ( 23 | 27 | )} 28 |
29 | ); 30 | }; 31 | 32 | export default CreatePoll; 33 | -------------------------------------------------------------------------------- /poll/app/src/PollClient.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Poll, PollSummary } from "@poll/model"; 3 | 4 | const client = axios.create({ 5 | baseURL: process.env.REACT_APP_POLL_SERVICE, 6 | timeout: 2000, 7 | headers: { 8 | "Content-Type": "application/json", 9 | }, 10 | }); 11 | 12 | export const createPoll = async (poll: Poll): Promise => { 13 | return await (await client.post("", poll)).data; 14 | }; 15 | 16 | export const getPoll = async (pollId: string): Promise => { 17 | return (await (await client.get(pollId)).data) as Poll; 18 | }; 19 | 20 | export const getPollSummary = async (pollId: string): Promise => { 21 | return await (await client.get(`${pollId}/summary`)).data as PollSummary; 22 | }; 23 | 24 | export const vote = async (pollId: string, option: number): Promise => { 25 | await client.post(pollId, { option }); 26 | }; 27 | -------------------------------------------------------------------------------- /poll/app/src/PollForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ChangeEvent } from "react"; 2 | import "./App.css"; 3 | import { Poll } from "@poll/model"; 4 | 5 | type Props = { 6 | poll: Poll; 7 | submit: (poll: Poll) => void; 8 | }; 9 | 10 | const PollForm = (props: Props) => { 11 | const [poll, setPoll] = useState(props.poll); 12 | 13 | const submit = () => { 14 | const options = poll.options.filter((opt) => opt.length > 0); 15 | props.submit({ topic: poll.topic, options }); 16 | }; 17 | 18 | const topicChanged = (event: ChangeEvent) => { 19 | setPoll({ ...poll, topic: event.target.value }); 20 | }; 21 | 22 | const answerChanged = (event: ChangeEvent) => { 23 | const options = [...poll.options]; 24 | const index = parseInt(event.target.id.substring(1)) - 1; 25 | options[index] = event.target.value; 26 | setPoll({ topic: poll.topic, options }); 27 | }; 28 | 29 | return ( 30 |
31 |
32 | 33 | 34 |
35 |
36 | 37 |
38 |
39 | 44 |
45 |
46 | 51 |
52 |
53 | 58 |
59 |
60 | 65 |
66 |
67 | 72 |
73 |
74 | 77 |
78 |
79 | ); 80 | }; 81 | 82 | export default PollForm; 83 | -------------------------------------------------------------------------------- /poll/app/src/PollInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./App.css"; 3 | 4 | type Props = { 5 | toShare: string; 6 | toSeeResults: string; 7 | }; 8 | 9 | const PollInfo = (props: Props) => { 10 | const fullUrl = `${window.location.protocol}//${window.location.host}${props.toShare}`; 11 | return ( 12 |
13 |
14 | Poll URL to share: {fullUrl} 15 |
16 |

17 |

18 | Please go here to see the results. 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default PollInfo; 25 | -------------------------------------------------------------------------------- /poll/app/src/PollSummary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PollSummary } from "@poll/model"; 3 | import "./App.css"; 4 | 5 | type Props = { 6 | summary: PollSummary; 7 | }; 8 | 9 | const PollSummaryView = (props: Props) => { 10 | const results = Object.entries(props.summary.result).map(option => { 11 | return ( 12 |
13 | 14 | 15 |
16 | ); 17 | }); 18 | 19 | return ( 20 |
21 |
22 | 23 |
24 | {results} 25 |
26 | ); 27 | }; 28 | 29 | export default PollSummaryView; 30 | -------------------------------------------------------------------------------- /poll/app/src/Routes.ts: -------------------------------------------------------------------------------- 1 | export const SUMMARY = "/result"; 2 | export const VOTE = "/vote"; -------------------------------------------------------------------------------- /poll/app/src/Summary.tsx: -------------------------------------------------------------------------------- 1 | import { PollSummary } from "@poll/model"; 2 | import React, { useState, useEffect } from "react"; 3 | import "./App.css"; 4 | import { getPollSummary } from "./PollClient"; 5 | import PollSummaryView from "./PollSummary"; 6 | 7 | const emptyPollSummary: PollSummary = { 8 | topic: "", 9 | result: {}, 10 | }; 11 | 12 | const Summary = () => { 13 | const [pollSummary, setPollSummary] = useState(emptyPollSummary); 14 | const pollId = window.location.pathname.substring( 15 | window.location.pathname.lastIndexOf("/") + 1 16 | ); 17 | 18 | useEffect(() => { 19 | getPollSummary(pollId).then(setPollSummary); 20 | }, [pollId]); 21 | 22 | return ( 23 |
24 | {pollSummary.topic.length > 0 ? ( 25 | 26 | ) : ( 27 |
Retrieving poll summary...
28 | )} 29 |
30 | ); 31 | }; 32 | 33 | export default Summary; 34 | -------------------------------------------------------------------------------- /poll/app/src/Vote.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import "./App.css"; 3 | import { emptyPoll } from "@poll/model"; 4 | import { getPoll, vote } from "./PollClient"; 5 | import VoteForm from "./VoteForm"; 6 | import { useLocation } from 'react-router-dom'; 7 | 8 | const Vote = () => { 9 | const [poll, setPoll] = useState(emptyPoll); 10 | const [voteProvided, setVoteProvided] = useState(false); 11 | const location = useLocation(); 12 | const pollId = location.pathname.substring( 13 | window.location.pathname.lastIndexOf("/") + 1 14 | ); 15 | 16 | useEffect(() => { 17 | getPoll(pollId).then(setPoll); 18 | }, [pollId]); 19 | 20 | const voted = (option: number) => { 21 | vote(pollId, option).then(_ => setVoteProvided(true)); 22 | }; 23 | 24 | return ( 25 |
26 | {poll ? ( 27 | voteProvided ? ( 28 |
Thank you!
29 | ) : ( 30 | 31 | ) 32 | ) : ( 33 |
Retrieving poll...
34 | )} 35 |
36 | ); 37 | }; 38 | 39 | export default Vote; 40 | -------------------------------------------------------------------------------- /poll/app/src/VoteForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ChangeEvent } from "react"; 2 | import { Poll } from "@poll/model"; 3 | import "./App.css"; 4 | 5 | const notSelected = -1; 6 | 7 | type Props = { 8 | poll: Poll; 9 | vote: (index: number) => void; 10 | }; 11 | 12 | const VoteForm = (props: Props) => { 13 | const [selected, setSelected] = useState(notSelected); 14 | 15 | const submit = () => { 16 | if (selected !== notSelected) { 17 | props.vote(selected); 18 | } 19 | }; 20 | 21 | const answerChanged = (event: ChangeEvent) => { 22 | setSelected(parseInt(event.target.id.substr(1, 1))); 23 | }; 24 | 25 | const getId = (index: number): string => "a" + index; 26 | 27 | const buttons = props.poll.options.map((option, index) => { 28 | return
29 | 37 | 38 |
; 39 | }); 40 | 41 | return ( 42 |
43 |
44 | 45 |
46 | {buttons} 47 |
48 | 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default VoteForm; 61 | -------------------------------------------------------------------------------- /poll/app/src/assets/raleway-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insify/serverless-typescript-monorepo-example/ae5bdbd21399b26e54687328a85ca0f71d79fe72/poll/app/src/assets/raleway-regular.woff -------------------------------------------------------------------------------- /poll/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ); 11 | -------------------------------------------------------------------------------- /poll/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "allowSyntheticDefaultImports": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react" 19 | }, 20 | "include": [ 21 | "src" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /poll/infra/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@poll/infra", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "serverless-pseudo-parameters": "^2.5.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /poll/infra/serverless.yml: -------------------------------------------------------------------------------- 1 | service: poll-infra 2 | 3 | plugins: 4 | - serverless-pseudo-parameters 5 | 6 | provider: 7 | name: aws 8 | region: eu-west-1 9 | stage: ${opt:stage, 'dev'} 10 | 11 | custom: 12 | poll_table_name: ${self:provider.stage}_poll 13 | 14 | resources: 15 | Resources: 16 | PollTable: 17 | Type: "AWS::DynamoDB::Table" 18 | Properties: 19 | TableName: ${self:custom.poll_table_name} 20 | AttributeDefinitions: 21 | - AttributeName: id 22 | AttributeType: S 23 | KeySchema: 24 | - AttributeName: id 25 | KeyType: HASH 26 | ProvisionedThroughput: 27 | ReadCapacityUnits: 1 28 | WriteCapacityUnits: 1 29 | 30 | Outputs: 31 | PollTableName: 32 | Value: ${self:custom.poll_table_name} 33 | Export: 34 | Name: ${self:provider.stage}-poll-table-name 35 | PollTableArn: 36 | Value: arn:aws:dynamodb:${self:provider.region}:#{AWS::AccountId}:table/${self:custom.poll_table_name} 37 | Export: 38 | Name: ${self:provider.stage}-poll-table-arn 39 | -------------------------------------------------------------------------------- /poll/model/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@poll/model", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "main": "dist/poll.js", 7 | "types": "dist/poll.d.ts", 8 | "scripts": { 9 | "build": "tsc" 10 | }, 11 | "devDependencies": { 12 | "typescript": "^4.0.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /poll/model/src/poll.ts: -------------------------------------------------------------------------------- 1 | export interface Poll { 2 | topic: string; 3 | options: string[]; 4 | } 5 | 6 | export interface OptionId { 7 | option: number; 8 | } 9 | 10 | export const emptyPoll = { 11 | topic: "", 12 | options: ["", "", "", "", ""], 13 | }; 14 | 15 | export interface PollResult { 16 | [name: string]: number; 17 | } 18 | 19 | export interface PollSummary { 20 | topic: string; 21 | result: PollResult; 22 | } 23 | -------------------------------------------------------------------------------- /poll/model/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["es2015"], 6 | "rootDir": "src/", 7 | "outDir": "dist", 8 | "declaration": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /poll/service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@poll/service", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "devDependencies": { 7 | "@types/aws-lambda": "^8.10.59", 8 | "@types/node": "^14.0.27", 9 | "@types/shortid": "^0.0.29", 10 | "aws-lambda": "^1.0.6", 11 | "aws-sdk": "^2.724.0", 12 | "serverless-plugin-typescript": "^1.1.9", 13 | "typescript": "^4.0.2" 14 | }, 15 | "dependencies": { 16 | "@poll/model": "1.0.0", 17 | "shortid": "^2.2.15" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /poll/service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: poll 2 | 3 | plugins: 4 | - serverless-plugin-typescript 5 | 6 | provider: 7 | name: aws 8 | runtime: nodejs12.x 9 | stage: ${opt:stage, 'dev'} 10 | region: eu-west-1 11 | iamRoleStatements: 12 | - Effect: "Allow" 13 | Action: 14 | - dynamodb:Query 15 | - dynamodb:Scan 16 | - dynamodb:GetItem 17 | - dynamodb:PutItem 18 | - dynamodb:UpdateItem 19 | - dynamodb:DeleteItem 20 | Resource: ${cf:poll-infra-${self:provider.stage}.PollTableArn} 21 | 22 | custom: 23 | poll_table_name: ${cf:poll-infra-${self:provider.stage}.PollTableName} 24 | 25 | functions: 26 | create: 27 | handler: src/creator.create 28 | environment: 29 | POLL_TABLE: ${self:custom.poll_table_name} 30 | events: 31 | - http: 32 | path: poll 33 | method: post 34 | cors: true 35 | 36 | getPoll: 37 | handler: src/poll.getPoll 38 | environment: 39 | POLL_TABLE: ${self:custom.poll_table_name} 40 | events: 41 | - http: 42 | path: poll/{id} 43 | method: get 44 | cors: true 45 | request: 46 | parameters: 47 | id: true 48 | 49 | vote: 50 | handler: src/poll.vote 51 | environment: 52 | POLL_TABLE: ${self:custom.poll_table_name} 53 | events: 54 | - http: 55 | path: poll/{id} 56 | method: post 57 | cors: true 58 | request: 59 | parameters: 60 | id: true 61 | 62 | getSummary: 63 | handler: src/poll.getSummary 64 | environment: 65 | POLL_TABLE: ${self:custom.poll_table_name} 66 | events: 67 | - http: 68 | path: poll/{id}/summary 69 | method: get 70 | cors: true 71 | request: 72 | parameters: 73 | id: true 74 | -------------------------------------------------------------------------------- /poll/service/src/creator.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent } from "aws-lambda"; 2 | import { Response, response } from "./response"; 3 | import { store, PollItem } from "./persister"; 4 | import { Poll } from "@poll/model"; 5 | import shortid from "shortid"; 6 | import { failure } from "./result-model"; 7 | 8 | export const create = async (event: APIGatewayEvent): Promise => { 9 | const pollOrError = toPoll(event); 10 | if (typeof pollOrError === "string") { 11 | return response(failure(pollOrError)); 12 | } 13 | const item = createItem(pollOrError); 14 | return response(await store(item)); 15 | }; 16 | 17 | const createItem = (poll: Poll): PollItem => { 18 | const id = shortid.generate(); 19 | const item = { id, poll } as PollItem; 20 | poll.options.forEach((_, index) => (item[`${index}`] = 0)); 21 | return item; 22 | }; 23 | 24 | const toPoll = (event: APIGatewayEvent): Poll | string => { 25 | if (!event.body) { 26 | return "Please provide data."; 27 | } 28 | const poll = JSON.parse(event.body) as Poll; 29 | if (!poll.topic) { 30 | return "Please provide a topic."; 31 | } 32 | if (!poll.options || poll.options.length <= 1) { 33 | return "Please provide more than one answer"; 34 | } 35 | return poll; 36 | }; 37 | -------------------------------------------------------------------------------- /poll/service/src/persister.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from "aws-sdk"; 2 | import { Poll, PollResult, PollSummary } from "@poll/model"; 3 | import { AttributeMap, DocumentClient } from "aws-sdk/clients/dynamodb"; 4 | import { 5 | Result, 6 | failure, 7 | fromAwsError, 8 | success, 9 | propagateFailure, 10 | } from "./result-model"; 11 | 12 | export interface PollItem { 13 | [column: string]: number | string | Poll; 14 | id: string; 15 | poll: Poll; 16 | } 17 | 18 | const dynamoDb = new DynamoDB.DocumentClient(); 19 | 20 | export const store = async (item: PollItem): Promise> => { 21 | const params = { 22 | TableName: process.env.POLL_TABLE || "", 23 | Item: item, 24 | }; 25 | const result = await dynamoDb.put(params).promise(); 26 | if (result.$response.error) { 27 | return fromAwsError(result.$response.error); 28 | } 29 | return success(item.id); 30 | }; 31 | 32 | export const getPollById = async (id: string): Promise> => { 33 | const item = await getById(id); 34 | if (item.result) { 35 | return success(item.result["poll"] as Poll); 36 | } 37 | return propagateFailure(item); 38 | }; 39 | 40 | export const countVote = async ( 41 | id: string, 42 | optionIndex: number 43 | ): Promise> => { 44 | const params = { 45 | ...getByIdParams(id), 46 | UpdateExpression: "set #option = #option + :val", 47 | ExpressionAttributeNames: { 48 | "#option": `${optionIndex}`, 49 | }, 50 | ExpressionAttributeValues: { 51 | ":val": 1, 52 | }, 53 | }; 54 | const result = await dynamoDb.update(params).promise(); 55 | if (result.$response.error) { 56 | fromAwsError(result.$response.error); 57 | } 58 | return success(id); 59 | }; 60 | 61 | export const getSummary = async (id: string): Promise> => { 62 | const itemOrError = await getById(id); 63 | if (!itemOrError.result) { 64 | return propagateFailure(itemOrError); 65 | } 66 | const item = itemOrError.result as PollItem; 67 | const result: PollResult = {}; 68 | item.poll.options.forEach((option, index) => { 69 | result[option] = Number(item[`${index}`]); 70 | }); 71 | return success({ topic: item.poll.topic, result }); 72 | }; 73 | 74 | const getById = async (id: string): Promise> => { 75 | const result = await dynamoDb.get(getByIdParams(id)).promise(); 76 | if (result.$response.error) { 77 | return fromAwsError(result.$response.error); 78 | } 79 | if (!result.Item) { 80 | return failure(`Could not find a poll with id ${id}`); 81 | } 82 | return success(result.Item); 83 | }; 84 | 85 | const getByIdParams = (id: string): DocumentClient.GetItemInput => { 86 | return { 87 | TableName: process.env.POLL_TABLE || "", 88 | Key: { id }, 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /poll/service/src/poll.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent } from "aws-lambda"; 2 | import { Response, response } from "./response"; 3 | import { getPollById, countVote, getSummary } from "./persister"; 4 | import { failure } from "./result-model"; 5 | 6 | export const getPoll = async (event: APIGatewayEvent): Promise => { 7 | if (!event.pathParameters?.id) { 8 | return response(failure("Please provide a poll id")); 9 | } 10 | return response(await getPollById(event.pathParameters.id)); 11 | }; 12 | 13 | export const vote = async (event: APIGatewayEvent): Promise => { 14 | if (!event.body) { 15 | return response(failure("Please provide an answer id")); 16 | } 17 | if (!event.pathParameters?.id) { 18 | return response(failure("Please provide a poll id")); 19 | } 20 | const { option } = JSON.parse(event.body); 21 | const result = await countVote(event.pathParameters.id, Number(option)); 22 | return response(result); 23 | }; 24 | 25 | export const summary = async (event: APIGatewayEvent): Promise => { 26 | if (!event.pathParameters?.id) { 27 | return response(failure("Please provide a poll id")); 28 | } 29 | return response(await getSummary(event.pathParameters.id)); 30 | }; 31 | -------------------------------------------------------------------------------- /poll/service/src/response.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "./result-model"; 2 | 3 | interface Headers { 4 | [name: string]: string; 5 | } 6 | 7 | const ALLOW_ORIGIN_HEADER = { 8 | "Access-Control-Allow-Origin": "*", 9 | }; 10 | 11 | export interface Response { 12 | statusCode: number; 13 | headers: Headers; 14 | body: string; 15 | } 16 | 17 | export const response = (result: Result): Response => { 18 | const code = result.error ? 400 : 200; 19 | const body = result.error 20 | ? result.error.message 21 | : typeof result.result === "string" 22 | ? result.result 23 | : JSON.stringify(result.result); 24 | return { 25 | statusCode: code, 26 | headers: ALLOW_ORIGIN_HEADER, 27 | body: body, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /poll/service/src/result-model.ts: -------------------------------------------------------------------------------- 1 | import { AWSError } from "aws-sdk"; 2 | 3 | export class Result { 4 | result: R | undefined; 5 | error: Error | undefined; 6 | } 7 | 8 | export const fromAwsError = (error: AWSError): Result => { 9 | return failure(error.message); 10 | }; 11 | 12 | export const propagateFailure = (result: Result): Result => { 13 | return failure(result.error!.message); 14 | } 15 | 16 | export const failure = (message: string): Result => { 17 | return { 18 | error: new Error(message), 19 | result: undefined, 20 | }; 21 | }; 22 | 23 | export const success = (result: R): Result => { 24 | return { 25 | error: undefined, 26 | result, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /poll/service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /poll/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "strict": true, 5 | "noImplicitReturns": true, 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "forceConsistentCasingInFileNames": true 9 | } 10 | } 11 | --------------------------------------------------------------------------------