├── .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 |
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 |
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 |
--------------------------------------------------------------------------------