├── .circleci
└── config.yml
├── .github
└── .kodiak.toml
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── frontend
├── .env
├── .env.example.local
├── .gitignore
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── manifest.json
├── serverless-single-page-app-plugin
│ ├── index.js
│ └── package.json
├── serverless.yml
├── src
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── __mocks__
│ │ └── auth0-js.ts
│ ├── __snapshots__
│ │ └── App.test.tsx.snap
│ ├── actions
│ │ ├── api.test.ts
│ │ ├── api.ts
│ │ ├── auth.test.ts
│ │ ├── auth.ts
│ │ └── types.ts
│ ├── components
│ │ ├── Api
│ │ │ ├── DbApi.test.tsx
│ │ │ ├── DbApi.tsx
│ │ │ ├── EmailApi.test.tsx
│ │ │ ├── EmailApi.tsx
│ │ │ ├── FileApi.test.tsx
│ │ │ ├── FileApi.tsx
│ │ │ ├── PrivateApi.test.tsx
│ │ │ ├── PrivateApi.tsx
│ │ │ ├── PublicApi.test.tsx
│ │ │ ├── PublicApi.tsx
│ │ │ ├── __snapshots__
│ │ │ │ ├── DbApi.test.tsx.snap
│ │ │ │ ├── EmailApi.test.tsx.snap
│ │ │ │ ├── FileApi.test.tsx.snap
│ │ │ │ ├── PrivateApi.test.tsx.snap
│ │ │ │ ├── PublicApi.test.tsx.snap
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── AuthStatus
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── Callback
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── Home
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── Loading
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── Login
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── NotFound
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── Private
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ └── Public
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── lib
│ │ ├── api.test.ts
│ │ ├── api.ts
│ │ ├── auth.test.ts
│ │ └── auth.ts
│ ├── react-app-env.d.ts
│ ├── reducers
│ │ ├── api.test.ts
│ │ ├── api.ts
│ │ ├── auth.test.ts
│ │ ├── auth.ts
│ │ └── index.ts
│ ├── registerServiceWorker.ts
│ ├── routes
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── sagas
│ │ ├── api.test.ts
│ │ ├── api.ts
│ │ ├── auth.test.ts
│ │ ├── auth.ts
│ │ ├── index.test.ts
│ │ └── index.ts
│ ├── selectors
│ │ ├── api.test.ts
│ │ ├── api.ts
│ │ ├── auth.test.ts
│ │ ├── auth.ts
│ │ ├── router.test.ts
│ │ └── router.ts
│ ├── setupTests.ts
│ └── types
│ │ └── redux.ts
├── tsconfig.json
└── tslint.json
├── lerna.json
├── package.json
├── renovate.json
├── scripts
└── stackOutput.js
├── services
├── api-service
│ ├── .gitignore
│ ├── README.md
│ ├── e2e
│ │ ├── config.example.ts
│ │ ├── privateEndpoint.test.ts
│ │ ├── publicEndpoint.chai.test.ts
│ │ ├── publicEndpoint.test.ts
│ │ ├── setup.ts
│ │ ├── setupFrameworks.ts
│ │ ├── setup_hook.js
│ │ └── utils
│ │ │ ├── auth0.ts
│ │ │ └── puppeteer.ts
│ ├── jest.config.e2e.js
│ ├── jest.config.js
│ ├── package.json
│ ├── serverless.yml
│ ├── src
│ │ ├── authHandler.test.ts
│ │ ├── authHandler.ts
│ │ ├── privateEndpointHandler.test.ts
│ │ ├── privateEndpointHandler.ts
│ │ ├── publicEndpointHandler.test.ts
│ │ └── publicEndpointHandler.ts
│ └── webpack.config.js
├── common
│ ├── .gitignore
│ ├── README.md
│ ├── environment
│ │ ├── config.example.json
│ │ └── public_key.example.pem
│ ├── jest.config.js
│ ├── package.json
│ └── src
│ │ └── auth
│ │ ├── authenticator.test.ts
│ │ └── authenticator.ts
├── db-service
│ ├── .gitignore
│ ├── README.md
│ ├── e2e
│ │ ├── db.chai.test.ts
│ │ ├── db.test.ts
│ │ ├── setup.ts
│ │ ├── setupFrameworks.ts
│ │ └── setup_hook.js
│ ├── jest.config.e2e.js
│ ├── jest.config.js
│ ├── package.json
│ ├── serverless.yml
│ ├── src
│ │ ├── create.test.ts
│ │ ├── create.ts
│ │ ├── db.test.ts
│ │ └── db.ts
│ └── webpack.config.js
├── email-service
│ ├── .gitignore
│ ├── README.md
│ ├── config.example.json
│ ├── jest.config.js
│ ├── package.json
│ ├── send-email-data.json
│ ├── serverless.yml
│ ├── src
│ │ ├── authHandler.test.ts
│ │ ├── authHandler.ts
│ │ ├── handler.test.ts
│ │ ├── handler.ts
│ │ ├── mailSender.test.ts
│ │ └── mailSender.ts
│ └── webpack.config.js
├── file-service
│ ├── .gitignore
│ ├── README.md
│ ├── e2e
│ │ ├── handler.chai.test.ts
│ │ ├── handler.test.ts
│ │ ├── setup.ts
│ │ ├── setupFrameworks.ts
│ │ └── setup_hook.js
│ ├── jest.config.e2e.js
│ ├── jest.config.js
│ ├── package.json
│ ├── serverless.yml
│ ├── src
│ │ ├── fileSaver.test.ts
│ │ ├── fileSaver.ts
│ │ ├── handler.test.ts
│ │ └── handler.ts
│ └── webpack.config.js
├── kinesis-service
│ ├── .gitignore
│ ├── README.md
│ ├── e2e
│ │ ├── handler.chai.test.ts
│ │ ├── handler.test.ts
│ │ ├── setup.ts
│ │ ├── setupFrameworks.ts
│ │ └── setup_hook.js
│ ├── jest.config.e2e.js
│ ├── jest.config.js
│ ├── package.json
│ ├── serverless.yml
│ ├── src
│ │ ├── handler.test.ts
│ │ ├── handler.ts
│ │ ├── kinesis.test.ts
│ │ └── kinesis.ts
│ └── webpack.config.js
├── tsconfig.json
└── tslint.json
└── yarn.lock
/.github/.kodiak.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [merge.automerge_dependencies]
4 | versions = ["minor", "patch"]
5 | usernames = ["renovate"]
6 |
7 | [approve]
8 | auto_approve_usernames = ["renovate"]
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 |
4 | #junit results
5 | reports
6 |
7 | lerna-debug.log
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true,
6 | "trailingComma": "all",
7 | "useTabs": false,
8 | "overrides": [
9 | {
10 | "files": "*.json",
11 | "options": { "printWidth": 200 }
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018, Erez Rokah.
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 |
--------------------------------------------------------------------------------
/frontend/.env:
--------------------------------------------------------------------------------
1 | BROWSER=none
--------------------------------------------------------------------------------
/frontend/.env.example.local:
--------------------------------------------------------------------------------
1 | REACT_APP_AUTH0_AUDIENCE=REPLACE_ME
2 | REACT_APP_AUTH0_CLIENT_ID=REPLACE_ME
3 | REACT_APP_AUTH0_DOMAIM=REPLACE_ME
4 | REACT_APP_AUTH0_REDIRECT_URI=http://localhost:3000/callback
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | .serverless
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "auth0-js": "^9.7.3",
7 | "connected-react-router": "^6.0.0",
8 | "react": "^17.0.0",
9 | "react-dom": "^17.0.0",
10 | "react-loadable": "^5.5.0",
11 | "react-redux": "^7.0.0",
12 | "react-router-dom": "^6.0.0",
13 | "react-scripts": "^5.0.0",
14 | "redux": "^4.0.0",
15 | "redux-actions": "^2.6.1",
16 | "redux-auth-wrapper": "^3.0.0",
17 | "redux-ignore": "^1.2.5",
18 | "redux-logger": "^3.0.6",
19 | "redux-saga": "^1.0.0",
20 | "reselect": "^4.0.0",
21 | "semantic-ui-css": "^2.4.0",
22 | "semantic-ui-react": "^2.0.0",
23 | "styled-components": "^5.0.0"
24 | },
25 | "scripts": {
26 | "analyze": "source-map-explorer build/static/js/main.*",
27 | "start": "react-scripts start",
28 | "build": "CI=true react-scripts build",
29 | "lint": "tslint '*.ts'",
30 | "test": "TZ=GMT CI=true react-scripts test --env=jsdom",
31 | "test:watch": "TZ=GMT react-scripts test --env=jsdom",
32 | "test:ci": "TZ=GMT CI=true JEST_JUNIT_OUTPUT=../reports/junit/${npm_package_name}-test-results.xml yarn test --runInBand --ci --reporters=jest-junit --reporters=default",
33 | "coverage": "TZ=GMT yarn test --coverage",
34 | "eject": "react-scripts eject",
35 | "deploy": "serverless deploy",
36 | "remove": "serverless remove",
37 | "publish": "serverless publishSite",
38 | "prettier": "prettier --write src/**/*.ts src/**/*.tsx serverless-single-page-app-plugin/*.js",
39 | "prettier:ci": "prettier --list-different src/**/*.ts src/**/*.tsx serverless-single-page-app-plugin/*.js"
40 | },
41 | "devDependencies": {
42 | "@anttiviljami/serverless-stack-output": "^0.3.1",
43 | "@types/auth0-js": "^9.13.4",
44 | "@types/enzyme": "^3.10.5",
45 | "@types/jest": "^27.0.0",
46 | "@types/node": "^16.0.0",
47 | "@types/react": "^17.0.0",
48 | "@types/react-dom": "^17.0.0",
49 | "@types/react-loadable": "^5.4.1",
50 | "@types/react-redux": "^7.0.0",
51 | "@types/react-router-dom": "^5.0.0",
52 | "@types/react-test-renderer": "^17.0.0",
53 | "@types/redux-actions": "^2.3.0",
54 | "@types/redux-auth-wrapper": "^2.0.8",
55 | "@types/redux-mock-store": "^1.0.0",
56 | "enzyme": "^3.9.0",
57 | "enzyme-adapter-react-16": "^1.12.1",
58 | "jest-styled-components": "^7.0.0",
59 | "mockdate": "^3.0.0",
60 | "react-test-renderer": "^17.0.0",
61 | "redux-mock-store": "^1.5.3",
62 | "serverless-s3-remover": "^0.6.0",
63 | "serverless-single-page-app-plugin": "file:./serverless-single-page-app-plugin",
64 | "source-map-explorer": "^2.0.0",
65 | "tslint": "^6.0.0",
66 | "tslint-config-prettier": "^1.18.0",
67 | "tslint-react": "^5.0.0",
68 | "typescript": "^4.0.0"
69 | },
70 | "jest": {
71 | "collectCoverageFrom": [
72 | "src/**/*.{js,jsx,ts,tsx}",
73 | "!src/registerServiceWorker.ts",
74 | "!src/index.tsx"
75 | ]
76 | },
77 | "author": "Erez Rokah",
78 | "license": "MIT",
79 | "browserslist": {
80 | "production": [
81 | ">0.2%",
82 | "not dead",
83 | "not op_mini all"
84 | ],
85 | "development": [
86 | "last 1 chrome version",
87 | "last 1 firefox version",
88 | "last 1 safari version"
89 | ]
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/erezrokah/serverless-monorepo-app/49044f3bf232cff49e1cca8114de6a0a05d1dad4/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
23 | Serverless App
24 |
25 |
26 |
27 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Serverless App",
3 | "name": "Serverless Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/serverless-single-page-app-plugin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "serverless-single-page-app-plugin",
3 | "version": "1.0.0",
4 | "description": "A plugin to simplify deploying Single Page Application using S3 and CloudFront",
5 | "author": "",
6 | "license": "MIT"
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as ReactDOM from 'react-dom';
4 | import { MemoryRouter } from 'react-router-dom';
5 | import * as renderer from 'react-test-renderer';
6 | import configureStore from 'redux-mock-store';
7 | import { syncAuthStateRequested as syncAuthState } from './actions/auth';
8 | import ConnectedApp, { App } from './App';
9 |
10 | const middlewares: any[] = [];
11 | const mockStore = configureStore(middlewares);
12 |
13 | jest.mock('./components/Api', () => 'api-component');
14 |
15 | jest.mock('./components/AuthStatus', () => 'auth-status-component');
16 |
17 | jest.mock('./components/Loading', () => {
18 | return {
19 | ComponentLoading: 'component-loading-component',
20 | default: 'loading-component',
21 | __esModule: true,
22 | };
23 | });
24 |
25 | jest.mock('./selectors/auth');
26 | jest.mock('./selectors/router');
27 |
28 | const props = {
29 | initialized: true,
30 | pathname: '/public',
31 | syncAuthState: jest.fn(),
32 | };
33 |
34 | describe('App', () => {
35 | beforeEach(() => {
36 | jest.clearAllMocks();
37 | });
38 |
39 | test('renders without crashing', () => {
40 | const div = document.createElement('div');
41 | ReactDOM.render(
42 |
43 |
44 | ,
45 | div,
46 | );
47 | ReactDOM.unmountComponentAtNode(div);
48 | });
49 |
50 | test('renders correctly initialized=true', () => {
51 | const tree = renderer
52 | .create(
53 |
54 |
55 | ,
56 | )
57 | .toJSON();
58 | expect(tree).toMatchSnapshot();
59 | });
60 |
61 | test('renders correctly initialized=false', () => {
62 | const tree = renderer
63 | .create()
64 | .toJSON();
65 | expect(tree).toMatchSnapshot();
66 | });
67 |
68 | test('syncs auth state on mount', () => {
69 | const wrapper = shallow(, {
70 | disableLifecycleMethods: true,
71 | });
72 |
73 | // @ts-ignore access to undefined
74 | wrapper.instance().componentDidMount();
75 |
76 | expect(props.syncAuthState).toHaveBeenCalledTimes(1);
77 | });
78 |
79 | test('should connect component correctly', () => {
80 | const { isInitialized } = require('./selectors/auth');
81 | isInitialized.mockReturnValueOnce(true);
82 |
83 | const { pathnameSelector } = require('./selectors/router');
84 | pathnameSelector.mockReturnValueOnce('somepath');
85 |
86 | const state = { someState: 'someState' };
87 | const store = mockStore(state);
88 |
89 | // @ts-ignore wrong store type
90 | const wrapper = shallow();
91 |
92 | expect(wrapper.props().initialized).toBe(true);
93 | expect(wrapper.props().pathname).toBe('somepath');
94 | wrapper.props().syncAuthState();
95 |
96 | expect(store.getActions()).toHaveLength(1);
97 | expect(store.getActions()[0]).toEqual(syncAuthState());
98 |
99 | expect(isInitialized).toHaveBeenCalledTimes(1);
100 | expect(isInitialized).toHaveBeenCalledWith(state);
101 |
102 | expect(pathnameSelector).toHaveBeenCalledTimes(1);
103 | expect(pathnameSelector).toHaveBeenCalledWith(state);
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link, Switch } from 'react-router-dom';
4 | import { Container, List, Segment } from 'semantic-ui-react';
5 | import { syncAuthStateRequested as syncAuthState } from './actions/auth';
6 | import Api from './components/Api';
7 | import AuthStatus from './components/AuthStatus';
8 | import Loading from './components/Loading';
9 | import { paths, routes } from './routes';
10 | import { isInitialized } from './selectors/auth';
11 | import { pathnameSelector } from './selectors/router';
12 | import { IState } from './types/redux';
13 |
14 | interface IStoreProps {
15 | initialized: boolean;
16 | pathname: string;
17 | }
18 |
19 | interface IProps extends IStoreProps {
20 | syncAuthState: typeof syncAuthState;
21 | }
22 |
23 | export class App extends React.Component {
24 | public componentDidMount() {
25 | this.props.syncAuthState();
26 | }
27 |
28 | public render() {
29 | if (!this.props.initialized) {
30 | return (
31 |
32 |
33 |
34 | );
35 | }
36 | const { pathname } = this.props;
37 | return (
38 |
39 |
40 |
41 |
46 | Public Page
47 |
48 |
53 | Private Page
54 |
55 |
56 |
57 |
58 | {routes.Home}
59 | {routes.Public}
60 | {routes.Private}
61 | {routes.Login}
62 | {routes.Callback}
63 | {routes.NotFound}
64 |
65 |
66 |
67 |
68 | );
69 | }
70 | }
71 |
72 | const mapStateToProps = (state: IState) => ({
73 | initialized: isInitialized(state),
74 | pathname: pathnameSelector(state),
75 | });
76 |
77 | export default connect(
78 | mapStateToProps,
79 | { syncAuthState },
80 | )(App);
81 |
--------------------------------------------------------------------------------
/frontend/src/__mocks__/auth0-js.ts:
--------------------------------------------------------------------------------
1 | export const authorize = jest.fn();
2 | export const parseHash = jest.fn();
3 |
4 | const WebAuth = jest.fn().mockImplementation(() => {
5 | return { authorize, parseHash };
6 | });
7 |
8 | export { WebAuth };
9 |
--------------------------------------------------------------------------------
/frontend/src/__snapshots__/App.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App renders correctly initialized=false 1`] = `
4 |
7 |
8 |
9 | `;
10 |
11 | exports[`App renders correctly initialized=true 1`] = `
12 |
15 |
40 |
43 | Welcome!
44 |
45 |
46 |
47 |
48 | `;
49 |
--------------------------------------------------------------------------------
/frontend/src/actions/api.test.ts:
--------------------------------------------------------------------------------
1 | import * as actions from './api';
2 | import * as types from './types';
3 |
4 | describe('Api Actions', () => {
5 | test(types.API_REQUESTED, () => {
6 | const type = 'type';
7 | expect({
8 | meta: { type },
9 | type: types.API_REQUESTED,
10 | }).toEqual(actions.apiRequested(undefined, type));
11 | });
12 |
13 | test(types.API_FULFILLED, () => {
14 | const payload = 'payload';
15 | const type = 'type';
16 | expect({
17 | meta: { type },
18 | payload,
19 | type: types.API_FULFILLED,
20 | }).toEqual(actions.apiFulfilled(payload, type));
21 | });
22 |
23 | test(types.API_REJECTED, () => {
24 | const error = new Error('error');
25 | const type = 'type';
26 | expect({
27 | error: true,
28 | meta: { type },
29 | payload: error,
30 | type: types.API_REJECTED,
31 | }).toEqual(actions.apiRejected(error, type));
32 | });
33 |
34 | test('emailApiRequested', () => {
35 | const toAddress = 'toAddress';
36 | expect({
37 | meta: { type: actions.apiMetaTypes.email },
38 | payload: toAddress,
39 | type: types.API_REQUESTED,
40 | }).toEqual(actions.emailApiRequested(toAddress));
41 | });
42 |
43 | test('privateApiRequested', () => {
44 | expect({
45 | meta: { type: actions.apiMetaTypes.private },
46 | type: types.API_REQUESTED,
47 | }).toEqual(actions.privateApiRequested());
48 | });
49 |
50 | test('publicApiRequested', () => {
51 | expect({
52 | meta: { type: actions.apiMetaTypes.public },
53 | type: types.API_REQUESTED,
54 | }).toEqual(actions.publicApiRequested());
55 | });
56 |
57 | test('fileApiRequested', () => {
58 | const fileUrl = 'fileUrl';
59 | const key = 'key';
60 | expect({
61 | meta: { type: actions.apiMetaTypes.file },
62 | payload: { fileUrl, key },
63 | type: types.API_REQUESTED,
64 | }).toEqual(actions.fileApiRequested(fileUrl, key));
65 | });
66 |
67 | test('dbCreateApiRequested', () => {
68 | const text = 'text';
69 | expect({
70 | meta: { type: actions.apiMetaTypes.dbCreate },
71 | payload: text,
72 | type: types.API_REQUESTED,
73 | }).toEqual(actions.dbCreateApiRequested(text));
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/frontend/src/actions/api.ts:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions';
2 | import * as types from './types';
3 |
4 | export const apiMetaTypes = {
5 | dbCreate: 'dbCreate',
6 | email: 'email',
7 | file: 'file',
8 | private: 'private',
9 | public: 'public',
10 | };
11 |
12 | export const apiRequested = createAction(
13 | types.API_REQUESTED,
14 | undefined,
15 | (_, type) => ({ type }),
16 | );
17 | export const apiFulfilled = createAction(
18 | types.API_FULFILLED,
19 | undefined,
20 | (_, type) => ({ type }),
21 | );
22 | export const apiRejected = createAction(
23 | types.API_REJECTED,
24 | undefined,
25 | (_, type) => ({ type }),
26 | );
27 |
28 | export const emailApiRequested = (toAddress: string) =>
29 | apiRequested(toAddress, apiMetaTypes.email);
30 |
31 | export const privateApiRequested = () =>
32 | apiRequested(undefined, apiMetaTypes.private);
33 |
34 | export const publicApiRequested = () =>
35 | apiRequested(undefined, apiMetaTypes.public);
36 |
37 | export const fileApiRequested = (fileUrl: string, key: string) =>
38 | apiRequested({ fileUrl, key }, apiMetaTypes.file);
39 |
40 | export const dbCreateApiRequested = (text: string) =>
41 | apiRequested(text, apiMetaTypes.dbCreate);
42 |
--------------------------------------------------------------------------------
/frontend/src/actions/auth.test.ts:
--------------------------------------------------------------------------------
1 | import * as actions from './auth';
2 | import * as types from './types';
3 |
4 | describe('Auth Actions', () => {
5 | test(types.LOGIN_REQUESTED, () => {
6 | expect({
7 | type: types.LOGIN_REQUESTED,
8 | }).toEqual(actions.loginRequested());
9 | });
10 |
11 | test(types.LOGIN_FULFILLED, () => {
12 | expect({
13 | type: types.LOGIN_FULFILLED,
14 | }).toEqual(actions.loginFulfilled());
15 | });
16 |
17 | test(types.LOGIN_REJECTED, () => {
18 | const error = new Error('Unauthorized');
19 | expect({
20 | error: true,
21 | payload: error,
22 | type: types.LOGIN_REJECTED,
23 | }).toEqual(actions.loginRejected(error));
24 | });
25 |
26 | test(types.LOGOUT_REQUESTED, () => {
27 | expect({
28 | type: types.LOGOUT_REQUESTED,
29 | }).toEqual(actions.logoutRequested());
30 | });
31 |
32 | test(types.LOGOUT_FULFILLED, () => {
33 | expect({
34 | type: types.LOGOUT_FULFILLED,
35 | }).toEqual(actions.logoutFulfilled());
36 | });
37 |
38 | test(types.LOGOUT_REJECTED, () => {
39 | const error = new Error('error');
40 | expect({
41 | error: true,
42 | payload: error,
43 | type: types.LOGOUT_REJECTED,
44 | }).toEqual(actions.logoutRejected(error));
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/frontend/src/actions/auth.ts:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions';
2 | import * as types from './types';
3 |
4 | export const loginRequested = createAction(types.LOGIN_REQUESTED);
5 | export const loginFulfilled = createAction(types.LOGIN_FULFILLED);
6 | export const loginRejected = createAction(types.LOGIN_REJECTED);
7 |
8 | export const logoutRequested = createAction(types.LOGOUT_REQUESTED);
9 | export const logoutFulfilled = createAction(types.LOGOUT_FULFILLED);
10 | export const logoutRejected = createAction(types.LOGOUT_REJECTED);
11 |
12 | export const syncAuthStateRequested = createAction(
13 | types.SYNC_AUTH_STATE_REQUESTED,
14 | );
15 | export const syncAuthStateFulfilled = createAction(
16 | types.SYNC_AUTH_STATE_FULFILLED,
17 | );
18 | export const syncAuthStateRejected = createAction(
19 | types.SYNC_AUTH_STATE_REJECTED,
20 | );
21 |
--------------------------------------------------------------------------------
/frontend/src/actions/types.ts:
--------------------------------------------------------------------------------
1 | const _REQUESTED = '_REQUESTED';
2 | const _FULFILLED = '_FULFILLED';
3 | const _REJECTED = '_REJECTED';
4 |
5 | export const LOGIN_REQUESTED = 'LOGIN' + _REQUESTED;
6 | export const LOGIN_FULFILLED = 'LOGIN' + _FULFILLED;
7 | export const LOGIN_REJECTED = 'LOGIN' + _REJECTED;
8 |
9 | export const LOGOUT_REQUESTED = 'LOGOUT' + _REQUESTED;
10 | export const LOGOUT_FULFILLED = 'LOGOUT' + _FULFILLED;
11 | export const LOGOUT_REJECTED = 'LOGOUT' + _REJECTED;
12 |
13 | export const SYNC_AUTH_STATE_REQUESTED = 'SYNC_AUTH_STATE' + _REQUESTED;
14 | export const SYNC_AUTH_STATE_FULFILLED = 'SYNC_AUTH_STATE' + _FULFILLED;
15 | export const SYNC_AUTH_STATE_REJECTED = 'SYNC_AUTH_STATE' + _REJECTED;
16 |
17 | export const API_REQUESTED = 'API' + _REQUESTED;
18 | export const API_FULFILLED = 'API' + _FULFILLED;
19 | export const API_REJECTED = 'API' + _REJECTED;
20 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/DbApi.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Form } from 'semantic-ui-react';
6 | import { dbCreateApiRequested } from '../../actions/api';
7 | import ConnectedDbApi, { DbApi } from './DbApi';
8 |
9 | const middlewares: any[] = [];
10 | const mockStore = configureStore(middlewares);
11 |
12 | jest.mock('../../selectors/api');
13 |
14 | const props = {
15 | dbCreateApiRequested: jest.fn(),
16 | error: null,
17 | inProgress: false,
18 | result: 'result',
19 | };
20 |
21 | describe('Db Api Component', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | test('should render correctly', () => {
27 | const tree = renderer.create().toJSON();
28 | expect(tree).toMatchSnapshot();
29 | });
30 |
31 | test('should render correctly with error', () => {
32 | const tree = renderer
33 | .create()
34 | .toJSON();
35 | expect(tree).toMatchSnapshot();
36 | });
37 |
38 | test('should call db api on button click', () => {
39 | const wrapper = shallow();
40 |
41 | const text = 'text';
42 |
43 | wrapper.setState({ text });
44 |
45 | wrapper.find(Form).simulate('submit');
46 |
47 | expect(props.dbCreateApiRequested).toHaveBeenCalledTimes(1);
48 | expect(props.dbCreateApiRequested).toHaveBeenCalledWith(text);
49 | });
50 |
51 | test('should update state on text change', () => {
52 | const wrapper = shallow();
53 |
54 | wrapper.setState({ text: '' });
55 | const text = 'text';
56 |
57 | wrapper.find('#dbApi').simulate('change', {}, { name: text, value: text });
58 |
59 | expect(wrapper.state()).toEqual({ text });
60 | });
61 |
62 | test('should connect component correctly', () => {
63 | const { dbCreateApiSelector } = require('../../selectors/api');
64 | const mockState = {
65 | error: null,
66 | inProgress: true,
67 | result: 'result',
68 | };
69 | dbCreateApiSelector.mockReturnValueOnce(mockState);
70 | const store = mockStore();
71 |
72 | // @ts-ignore wrong store type
73 | const wrapper = shallow();
74 |
75 | expect(wrapper.props().error).toBe(mockState.error);
76 | expect(wrapper.props().inProgress).toBe(mockState.inProgress);
77 | expect(wrapper.props().result).toBe(mockState.result);
78 | const text = 'text';
79 | wrapper.props().dbCreateApiRequested(text);
80 |
81 | expect(store.getActions()).toHaveLength(1);
82 | expect(store.getActions()[0]).toEqual(dbCreateApiRequested(text));
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/DbApi.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Button, Form, Message, InputOnChangeData } from 'semantic-ui-react';
4 | import { dbCreateApiRequested } from '../../actions/api';
5 | import { dbCreateApiSelector } from '../../selectors/api';
6 | import { IApiState, IState as Store } from '../../types/redux';
7 |
8 | interface IState {
9 | [key: string]: string;
10 | }
11 |
12 | interface IProps extends IApiState {
13 | dbCreateApiRequested: typeof dbCreateApiRequested;
14 | }
15 |
16 | interface IEventProps extends InputOnChangeData {
17 | name: string;
18 | }
19 |
20 | export class DbApi extends React.Component {
21 | constructor(props: IProps) {
22 | super(props);
23 | this.state = { text: '' };
24 | }
25 |
26 | public render() {
27 | const { text } = this.state;
28 | const { error, inProgress, result } = this.props;
29 | return (
30 |
37 |
46 |
47 |
48 |
49 |
50 |
55 |
56 | );
57 | }
58 |
59 | private handleChange = (
60 | _: React.SyntheticEvent,
61 | data: InputOnChangeData,
62 | ) => {
63 | const { name, value } = data as IEventProps;
64 | this.setState({ [name]: value });
65 | };
66 |
67 | private onFileSave = () => this.props.dbCreateApiRequested(this.state.text);
68 | }
69 |
70 | const mapStateToProps = (state: Store) => ({
71 | ...dbCreateApiSelector(state),
72 | });
73 |
74 | export default connect(
75 | mapStateToProps,
76 | { dbCreateApiRequested },
77 | )(DbApi);
78 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/EmailApi.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Form } from 'semantic-ui-react';
6 | import { emailApiRequested } from '../../actions/api';
7 | import ConnectedEmailApi, { EmailApi } from './EmailApi';
8 |
9 | const middlewares: any[] = [];
10 | const mockStore = configureStore(middlewares);
11 |
12 | jest.mock('../../selectors/api');
13 |
14 | const props = {
15 | emailApiRequested: jest.fn(),
16 | error: null,
17 | inProgress: false,
18 | result: 'result',
19 | };
20 |
21 | describe('Email Api Component', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | test('should render correctly', () => {
27 | const tree = renderer.create().toJSON();
28 | expect(tree).toMatchSnapshot();
29 | });
30 |
31 | test('should render correctly with error', () => {
32 | const tree = renderer
33 | .create()
34 | .toJSON();
35 | expect(tree).toMatchSnapshot();
36 | });
37 |
38 | test('should call email api on button click', () => {
39 | const wrapper = shallow();
40 |
41 | const email = 'email';
42 |
43 | wrapper.setState({ email });
44 |
45 | wrapper.find(Form).simulate('submit');
46 |
47 | expect(props.emailApiRequested).toHaveBeenCalledTimes(1);
48 | expect(props.emailApiRequested).toHaveBeenCalledWith(email);
49 | });
50 |
51 | test('should update state on email change', () => {
52 | const wrapper = shallow();
53 |
54 | wrapper.setState({ email: '' });
55 | const email = 'email';
56 |
57 | wrapper
58 | .find('#emailApi')
59 | .simulate('change', {}, { name: email, value: email });
60 |
61 | expect(wrapper.state()).toEqual({ email });
62 | });
63 |
64 | test('should connect component correctly', () => {
65 | const { emailApiSelector } = require('../../selectors/api');
66 | const mockState = {
67 | error: null,
68 | inProgress: true,
69 | result: 'result',
70 | };
71 | emailApiSelector.mockReturnValueOnce(mockState);
72 | const store = mockStore();
73 |
74 | // @ts-ignore wrong store type
75 | const wrapper = shallow();
76 |
77 | expect(wrapper.props().error).toBe(mockState.error);
78 | expect(wrapper.props().inProgress).toBe(mockState.inProgress);
79 | expect(wrapper.props().result).toBe(mockState.result);
80 | const toAddress = 'toAddress';
81 | wrapper.props().emailApiRequested(toAddress);
82 |
83 | expect(store.getActions()).toHaveLength(1);
84 | expect(store.getActions()[0]).toEqual(emailApiRequested(toAddress));
85 | });
86 | });
87 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/EmailApi.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Button, Form, Message, InputOnChangeData } from 'semantic-ui-react';
4 | import { emailApiRequested } from '../../actions/api';
5 | import { emailApiSelector } from '../../selectors/api';
6 | import { IApiState, IState as Store } from '../../types/redux';
7 |
8 | interface IState {
9 | [key: string]: string;
10 | }
11 |
12 | interface IProps extends IApiState {
13 | emailApiRequested: typeof emailApiRequested;
14 | }
15 |
16 | interface IEventProps {
17 | name: string;
18 | value: string;
19 | }
20 |
21 | export class EmailApi extends React.Component {
22 | constructor(props: IProps) {
23 | super(props);
24 | this.state = { email: '' };
25 | }
26 |
27 | public render() {
28 | const { email } = this.state;
29 | const { error, inProgress, result } = this.props;
30 | return (
31 |
38 |
47 |
48 |
49 |
50 |
55 |
56 | );
57 | }
58 |
59 | private handleChange = (
60 | _: React.SyntheticEvent,
61 | data: InputOnChangeData,
62 | ) => {
63 | const { name, value } = data as IEventProps;
64 | this.setState({ [name]: value });
65 | };
66 |
67 | private onEmailSend = () => this.props.emailApiRequested(this.state.email);
68 | }
69 |
70 | const mapStateToProps = (state: Store) => ({
71 | ...emailApiSelector(state),
72 | });
73 |
74 | export default connect(
75 | mapStateToProps,
76 | { emailApiRequested },
77 | )(EmailApi);
78 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/FileApi.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Form } from 'semantic-ui-react';
6 | import { fileApiRequested } from '../../actions/api';
7 | import ConnectedFileApi, { FileApi } from './FileApi';
8 |
9 | const middlewares: any[] = [];
10 | const mockStore = configureStore(middlewares);
11 |
12 | jest.mock('../../selectors/api');
13 |
14 | const props = {
15 | error: null,
16 | fileApiRequested: jest.fn(),
17 | inProgress: false,
18 | result: 'result',
19 | };
20 |
21 | describe('File Api Component', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | test('should render correctly', () => {
27 | const tree = renderer.create().toJSON();
28 | expect(tree).toMatchSnapshot();
29 | });
30 |
31 | test('should render correctly with error', () => {
32 | const tree = renderer
33 | .create()
34 | .toJSON();
35 | expect(tree).toMatchSnapshot();
36 | });
37 |
38 | test('should call file api on button click', () => {
39 | const wrapper = shallow();
40 |
41 | const fileUrl = 'fileUrl';
42 | const fileName = 'fileName';
43 |
44 | wrapper.setState({ fileUrl, fileName });
45 |
46 | wrapper.find(Form).simulate('submit');
47 |
48 | expect(props.fileApiRequested).toHaveBeenCalledTimes(1);
49 | expect(props.fileApiRequested).toHaveBeenCalledWith(fileUrl, fileName);
50 | });
51 |
52 | test('should update state on input changes', () => {
53 | const wrapper = shallow();
54 |
55 | wrapper.setState({ fileUrl: '', fileName: '' });
56 | const fileUrl = 'fileUrl';
57 | const fileName = 'fileName';
58 |
59 | wrapper
60 | .find('#fileUrl')
61 | .simulate('change', {}, { name: 'fileUrl', value: 'fileUrl' });
62 | wrapper
63 | .find('#fileName')
64 | .simulate('change', {}, { name: 'fileName', value: 'fileName' });
65 |
66 | expect(wrapper.state()).toEqual({ fileUrl, fileName });
67 | });
68 |
69 | test('should connect component correctly', () => {
70 | const { fileApiSelector } = require('../../selectors/api');
71 | const mockState = {
72 | error: null,
73 | inProgress: true,
74 | result: 'result',
75 | };
76 | fileApiSelector.mockReturnValueOnce(mockState);
77 | const store = mockStore();
78 |
79 | // @ts-ignore wrong store type
80 | const wrapper = shallow();
81 |
82 | expect(wrapper.props().error).toBe(mockState.error);
83 | expect(wrapper.props().inProgress).toBe(mockState.inProgress);
84 | expect(wrapper.props().result).toBe(mockState.result);
85 | const fileUrl = 'fileUrl';
86 | const fileName = 'fileName';
87 | wrapper.props().fileApiRequested(fileUrl, fileName);
88 |
89 | expect(store.getActions()).toHaveLength(1);
90 | expect(store.getActions()[0]).toEqual(fileApiRequested(fileUrl, fileName));
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/FileApi.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Button, Form, Message, InputOnChangeData } from 'semantic-ui-react';
4 | import { fileApiRequested } from '../../actions/api';
5 | import { fileApiSelector } from '../../selectors/api';
6 | import { IApiState, IState as Store } from '../../types/redux';
7 |
8 | interface IState {
9 | [key: string]: string;
10 | }
11 |
12 | interface IProps extends IApiState {
13 | fileApiRequested: typeof fileApiRequested;
14 | }
15 |
16 | interface IEventProps {
17 | name: string;
18 | value: string;
19 | }
20 |
21 | export class FileApi extends React.Component {
22 | constructor(props: IProps) {
23 | super(props);
24 | this.state = { fileUrl: '', fileName: '' };
25 | }
26 |
27 | public render() {
28 | const { fileUrl, fileName } = this.state;
29 | const { error, inProgress, result } = this.props;
30 | return (
31 |
38 |
47 |
56 |
57 |
58 |
59 |
64 |
65 | );
66 | }
67 |
68 | private handleChange = (
69 | _: React.SyntheticEvent,
70 | data: InputOnChangeData,
71 | ) => {
72 | const { name, value } = data as IEventProps;
73 | this.setState({ [name]: value });
74 | };
75 |
76 | private onFileSave = () =>
77 | this.props.fileApiRequested(this.state.fileUrl, this.state.fileName);
78 | }
79 |
80 | const mapStateToProps = (state: Store) => ({
81 | ...fileApiSelector(state),
82 | });
83 |
84 | export default connect(
85 | mapStateToProps,
86 | { fileApiRequested },
87 | )(FileApi);
88 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/PrivateApi.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Button } from 'semantic-ui-react';
6 | import { privateApiRequested } from '../../actions/api';
7 | import ConnectedPrivateApi, { PrivateApi } from './PrivateApi';
8 |
9 | const middlewares: any[] = [];
10 | const mockStore = configureStore(middlewares);
11 |
12 | jest.mock('../../selectors/api');
13 |
14 | const props = {
15 | error: null,
16 | inProgress: false,
17 | privateApiRequested: jest.fn(),
18 | result: 'result',
19 | };
20 |
21 | describe('Private Api Component', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | test('should render correctly', () => {
27 | const tree = renderer.create().toJSON();
28 | expect(tree).toMatchSnapshot();
29 | });
30 |
31 | test('should render correctly with error', () => {
32 | const tree = renderer
33 | .create()
34 | .toJSON();
35 | expect(tree).toMatchSnapshot();
36 | });
37 |
38 | test('should call private api on button click', () => {
39 | const wrapper = shallow();
40 |
41 | wrapper.find(Button).simulate('click');
42 |
43 | expect(props.privateApiRequested).toHaveBeenCalledTimes(1);
44 | });
45 |
46 | test('should connect component correctly', () => {
47 | const { privateApiSelector } = require('../../selectors/api');
48 | const mockState = {
49 | error: null,
50 | inProgress: true,
51 | result: 'result',
52 | };
53 | privateApiSelector.mockReturnValueOnce(mockState);
54 | const store = mockStore();
55 |
56 | // @ts-ignore wrong store type
57 | const wrapper = shallow();
58 |
59 | expect(wrapper.props().error).toBe(mockState.error);
60 | expect(wrapper.props().inProgress).toBe(mockState.inProgress);
61 | expect(wrapper.props().result).toBe(mockState.result);
62 | wrapper.props().privateApiRequested();
63 |
64 | expect(store.getActions()).toHaveLength(1);
65 | expect(store.getActions()[0]).toEqual(privateApiRequested());
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/PrivateApi.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Button, Message } from 'semantic-ui-react';
4 | import { privateApiRequested } from '../../actions/api';
5 | import { privateApiSelector } from '../../selectors/api';
6 | import { IApiState, IState as Store } from '../../types/redux';
7 |
8 | interface IProps extends IApiState {
9 | privateApiRequested: typeof privateApiRequested;
10 | }
11 |
12 | export class PrivateApi extends React.Component {
13 | public render() {
14 | const { error, inProgress, result } = this.props;
15 |
16 | const ResultComponent =
17 | !error && result ? (
18 |
19 | ) : null;
20 |
21 | const ErrorComponent = error ? (
22 |
27 | ) : null;
28 |
29 | return (
30 |
31 |
38 | {ResultComponent}
39 | {ErrorComponent}
40 |
41 | );
42 | }
43 | }
44 |
45 | const mapStateToProps = (state: Store) => ({
46 | ...privateApiSelector(state),
47 | });
48 |
49 | export default connect(
50 | mapStateToProps,
51 | { privateApiRequested },
52 | )(PrivateApi);
53 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/PublicApi.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Button } from 'semantic-ui-react';
6 | import { publicApiRequested } from '../../actions/api';
7 | import ConnectedPublicApi, { PublicApi } from './PublicApi';
8 |
9 | const middlewares: any[] = [];
10 | const mockStore = configureStore(middlewares);
11 |
12 | jest.mock('../../selectors/api');
13 |
14 | const props = {
15 | error: null,
16 | inProgress: false,
17 | publicApiRequested: jest.fn(),
18 | result: 'result',
19 | };
20 |
21 | describe('Public Api Component', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | test('should render correctly', () => {
27 | const tree = renderer.create().toJSON();
28 | expect(tree).toMatchSnapshot();
29 | });
30 |
31 | test('should render correctly with error', () => {
32 | const tree = renderer
33 | .create()
34 | .toJSON();
35 | expect(tree).toMatchSnapshot();
36 | });
37 |
38 | test('should call public api on button click', () => {
39 | const wrapper = shallow();
40 |
41 | wrapper.find(Button).simulate('click');
42 |
43 | expect(props.publicApiRequested).toHaveBeenCalledTimes(1);
44 | });
45 |
46 | test('should connect component correctly', () => {
47 | const { publicApiSelector } = require('../../selectors/api');
48 | const mockState = {
49 | error: null,
50 | inProgress: true,
51 | result: 'result',
52 | };
53 | publicApiSelector.mockReturnValueOnce(mockState);
54 | const store = mockStore();
55 |
56 | // @ts-ignore wrong store type
57 | const wrapper = shallow();
58 |
59 | expect(wrapper.props().error).toBe(mockState.error);
60 | expect(wrapper.props().inProgress).toBe(mockState.inProgress);
61 | expect(wrapper.props().result).toBe(mockState.result);
62 | wrapper.props().publicApiRequested();
63 |
64 | expect(store.getActions()).toHaveLength(1);
65 | expect(store.getActions()[0]).toEqual(publicApiRequested());
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/PublicApi.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Button, Message } from 'semantic-ui-react';
4 | import { publicApiRequested } from '../../actions/api';
5 | import { publicApiSelector } from '../../selectors/api';
6 | import { IApiState, IState as Store } from '../../types/redux';
7 |
8 | interface IProps extends IApiState {
9 | publicApiRequested: typeof publicApiRequested;
10 | }
11 |
12 | export class PublicApi extends React.Component {
13 | public render() {
14 | const { error, inProgress, result } = this.props;
15 |
16 | const ResultComponent =
17 | !error && result ? (
18 |
19 | ) : null;
20 |
21 | const ErrorComponent = error ? (
22 |
27 | ) : null;
28 |
29 | return (
30 |
31 |
38 | {ResultComponent}
39 | {ErrorComponent}
40 |
41 | );
42 | }
43 | }
44 |
45 | const mapStateToProps = (state: Store) => ({
46 | ...publicApiSelector(state),
47 | });
48 |
49 | export default connect(
50 | mapStateToProps,
51 | { publicApiRequested },
52 | )(PublicApi);
53 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/__snapshots__/DbApi.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Db Api Component should render correctly 1`] = `
4 |
66 | `;
67 |
68 | exports[`Db Api Component should render correctly with error 1`] = `
69 |
134 | `;
135 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/__snapshots__/EmailApi.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Email Api Component should render correctly 1`] = `
4 |
66 | `;
67 |
68 | exports[`Email Api Component should render correctly with error 1`] = `
69 |
134 | `;
135 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/__snapshots__/FileApi.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`File Api Component should render correctly 1`] = `
4 |
83 | `;
84 |
85 | exports[`File Api Component should render correctly with error 1`] = `
86 |
168 | `;
169 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/__snapshots__/PrivateApi.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Private Api Component should render correctly 1`] = `
4 | Array [
5 | ,
12 |
15 |
18 |
21 | Private Api Result
22 |
23 |
24 | result
25 |
26 |
27 |
,
28 | ]
29 | `;
30 |
31 | exports[`Private Api Component should render correctly with error 1`] = `
32 | Array [
33 | ,
40 |
43 |
46 |
49 | Error Accessing Private Api
50 |
51 |
52 | error
53 |
54 |
55 |
,
56 | ]
57 | `;
58 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/__snapshots__/PublicApi.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Public Api Component should render correctly 1`] = `
4 | Array [
5 | ,
12 |
15 |
18 |
21 | Public Api Result
22 |
23 |
24 | result
25 |
26 |
27 |
,
28 | ]
29 | `;
30 |
31 | exports[`Public Api Component should render correctly with error 1`] = `
32 | Array [
33 | ,
40 |
43 |
46 |
49 | Error Accessing Public Api
50 |
51 |
52 | error
53 |
54 |
55 |
,
56 | ]
57 | `;
58 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Api Component should render correctly 1`] = `
4 |
7 |
10 |
11 |
12 |
17 |
22 |
25 |
26 |
27 |
30 |
31 |
32 |
33 | `;
34 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as renderer from 'react-test-renderer';
3 | import Api from './';
4 |
5 | jest.mock('./EmailApi', () => 'EmailApi');
6 | jest.mock('./PrivateApi', () => 'PrivateApi');
7 | jest.mock('./PublicApi', () => 'PublicApi');
8 | jest.mock('./DbApi', () => 'DbApi');
9 | jest.mock('./FileApi', () => 'FileApi');
10 |
11 | describe('Api Component', () => {
12 | test('should render correctly', () => {
13 | const tree = renderer.create().toJSON();
14 | expect(tree).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/frontend/src/components/Api/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Segment } from 'semantic-ui-react';
3 | import DbApi from './DbApi';
4 | import EmailApi from './EmailApi';
5 | import FileApi from './FileApi';
6 | import PrivateApi from './PrivateApi';
7 | import PublicApi from './PublicApi';
8 |
9 | export class Api extends React.Component {
10 | public render() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | }
32 |
33 | export default Api;
34 |
--------------------------------------------------------------------------------
/frontend/src/components/AuthStatus/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`AuthStatus Component should render correctly authenticated=false 1`] = `
4 |
7 | You are not logged in.
8 |
9 | `;
10 |
11 | exports[`AuthStatus Component should render correctly authenticated=true 1`] = `
12 |
15 |
22 |
23 | `;
24 |
--------------------------------------------------------------------------------
/frontend/src/components/AuthStatus/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Button } from 'semantic-ui-react';
6 | import { logoutRequested as logout } from '../../actions/auth';
7 | import ConnectedAuthStatus, { AuthStatus } from './';
8 |
9 | jest.mock('../../selectors/auth');
10 |
11 | const middlewares: any[] = [];
12 | const mockStore = configureStore(middlewares);
13 |
14 | const props = {
15 | authenticated: false,
16 | logout: jest.fn(),
17 | };
18 |
19 | describe('AuthStatus Component', () => {
20 | beforeEach(() => {
21 | jest.clearAllMocks();
22 | });
23 | test('should render correctly authenticated=false', () => {
24 | const tree = renderer
25 | .create()
26 | .toJSON();
27 | expect(tree).toMatchSnapshot();
28 | });
29 |
30 | test('should render correctly authenticated=true', () => {
31 | const tree = renderer
32 | .create()
33 | .toJSON();
34 | expect(tree).toMatchSnapshot();
35 | });
36 |
37 | test('should call logout on button click', () => {
38 | const wrapper = shallow();
39 |
40 | wrapper.find(Button).simulate('click');
41 |
42 | expect(props.logout).toHaveBeenCalledTimes(1);
43 | });
44 |
45 | test('should connect component correctly', () => {
46 | const { isAuthenticated } = require('../../selectors/auth');
47 | isAuthenticated.mockReturnValueOnce(true);
48 |
49 | const state = { someState: 'someState' };
50 | const store = mockStore(state);
51 |
52 | // @ts-ignore wrong store type
53 | const wrapper = shallow();
54 |
55 | expect(wrapper.props().authenticated).toBe(true);
56 | wrapper.props().logout();
57 |
58 | expect(store.getActions()).toHaveLength(1);
59 | expect(store.getActions()[0]).toEqual(logout());
60 |
61 | expect(isAuthenticated).toHaveBeenCalledTimes(1);
62 | expect(isAuthenticated).toHaveBeenCalledWith(state);
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/frontend/src/components/AuthStatus/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Button, Segment } from 'semantic-ui-react';
4 | import { logoutRequested as logout } from '../../actions/auth';
5 | import { isAuthenticated } from '../../selectors/auth';
6 | import { IState } from '../../types/redux';
7 |
8 | interface IStoreProps {
9 | authenticated: boolean;
10 | }
11 |
12 | interface IProps extends IStoreProps {
13 | logout: typeof logout;
14 | }
15 |
16 | export const AuthStatus: React.SFC = props => {
17 | if (props.authenticated) {
18 | return (
19 |
20 |
21 |
22 | );
23 | } else {
24 | return You are not logged in.;
25 | }
26 | };
27 |
28 | const mapStateToProps = (state: IState): IStoreProps => ({
29 | authenticated: isAuthenticated(state),
30 | });
31 |
32 | export default connect(
33 | mapStateToProps,
34 | { logout },
35 | )(AuthStatus);
36 |
--------------------------------------------------------------------------------
/frontend/src/components/Callback/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Callback Component should render correctly 1`] = ``;
4 |
--------------------------------------------------------------------------------
/frontend/src/components/Callback/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { replace } from 'connected-react-router';
2 | import { shallow } from 'enzyme';
3 | import * as React from 'react';
4 | import * as renderer from 'react-test-renderer';
5 | import configureStore from 'redux-mock-store';
6 | import { loginFulfilled, loginRejected } from '../../actions/auth';
7 | import { paths } from '../../routes';
8 | import ConnectedCallback, { Callback } from './';
9 |
10 | jest.mock('../../lib/auth');
11 | jest.mock('../Loading', () => {
12 | return {
13 | ComponentLoading: 'component-loading-component',
14 | default: 'loading-component',
15 | __esModule: true,
16 | };
17 | });
18 |
19 | const middlewares: any[] = [];
20 | const mockStore = configureStore(middlewares);
21 |
22 | const props = {
23 | loginFulfilled: jest.fn(),
24 | loginRejected: jest.fn(),
25 | replace: jest.fn(),
26 | };
27 |
28 | describe('Callback Component', () => {
29 | beforeEach(() => {
30 | jest.clearAllMocks();
31 | });
32 |
33 | test('should render correctly', () => {
34 | const tree = renderer.create().toJSON();
35 | expect(tree).toMatchSnapshot();
36 | });
37 |
38 | test('handles authentication on mount with auth state', async () => {
39 | const { handleAuthentication } = require('../../lib/auth');
40 |
41 | const wrapper = shallow(, {
42 | disableLifecycleMethods: true,
43 | });
44 |
45 | const authResult = { state: 'state' };
46 | handleAuthentication.mockReturnValueOnce(Promise.resolve(authResult));
47 |
48 | // @ts-ignore access to undefined
49 | await wrapper.instance().componentDidMount();
50 |
51 | expect(handleAuthentication).toHaveBeenCalledTimes(1);
52 | expect(props.loginFulfilled).toHaveBeenCalledTimes(1);
53 | expect(props.loginFulfilled).toHaveBeenCalledWith(authResult);
54 | expect(props.replace).toHaveBeenCalledTimes(1);
55 | expect(props.replace).toHaveBeenCalledWith(authResult.state);
56 | });
57 |
58 | test('handles authentication on mount no auth state', async () => {
59 | const { handleAuthentication } = require('../../lib/auth');
60 |
61 | const wrapper = shallow(, {
62 | disableLifecycleMethods: true,
63 | });
64 |
65 | const authResult = {};
66 | handleAuthentication.mockReturnValueOnce(Promise.resolve(authResult));
67 |
68 | // @ts-ignore access to undefined
69 | await wrapper.instance().componentDidMount();
70 |
71 | expect(props.replace).toHaveBeenCalledWith('/public');
72 | });
73 |
74 | test('handles authentication on mount with error', async () => {
75 | const { handleAuthentication } = require('../../lib/auth');
76 |
77 | const wrapper = shallow(, {
78 | disableLifecycleMethods: true,
79 | });
80 |
81 | const error = new Error('error');
82 | handleAuthentication.mockReturnValueOnce(Promise.reject(error));
83 |
84 | // @ts-ignore access to undefined
85 | await wrapper.instance().componentDidMount();
86 |
87 | expect(props.loginRejected).toHaveBeenCalledTimes(1);
88 | expect(props.loginRejected).toHaveBeenCalledWith(error);
89 | expect(props.replace).toHaveBeenCalledTimes(1);
90 | expect(props.replace).toHaveBeenCalledWith(paths.login);
91 | });
92 |
93 | test('should connect component correctly', () => {
94 | const store = mockStore();
95 |
96 | // @ts-ignore wrong store type
97 | const wrapper = shallow();
98 |
99 | wrapper.props().loginFulfilled();
100 | wrapper.props().loginRejected();
101 | wrapper.props().replace('/');
102 |
103 | expect(store.getActions()).toHaveLength(3);
104 | expect(store.getActions()[0]).toEqual(loginFulfilled());
105 | expect(store.getActions()[1]).toEqual(loginRejected());
106 | expect(store.getActions()[2]).toEqual(replace(paths.home));
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/frontend/src/components/Callback/index.tsx:
--------------------------------------------------------------------------------
1 | import { replace } from 'connected-react-router';
2 | import * as React from 'react';
3 | import { connect } from 'react-redux';
4 | import { loginFulfilled, loginRejected } from '../../actions/auth';
5 | import { handleAuthentication } from '../../lib/auth';
6 | import { paths } from '../../routes';
7 | import Loading from '../Loading';
8 |
9 | interface IProps {
10 | loginFulfilled: typeof loginFulfilled;
11 | loginRejected: typeof loginRejected;
12 | replace: typeof replace;
13 | }
14 |
15 | export class Callback extends React.Component {
16 | public async componentDidMount() {
17 | try {
18 | const authResult = await handleAuthentication();
19 | this.props.loginFulfilled(authResult);
20 | this.props.replace(authResult.state || paths.public);
21 | } catch (err) {
22 | this.props.loginRejected(err);
23 | this.props.replace(paths.login);
24 | }
25 | }
26 |
27 | public render() {
28 | return ;
29 | }
30 | }
31 |
32 | export default connect(
33 | null,
34 | { loginFulfilled, loginRejected, replace },
35 | )(Callback);
36 |
--------------------------------------------------------------------------------
/frontend/src/components/Home/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Home Component should render correctly 1`] = `
4 |
7 | Welcome!
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/frontend/src/components/Home/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as renderer from 'react-test-renderer';
3 | import Home from './';
4 |
5 | describe('Home Component', () => {
6 | test('should render correctly', () => {
7 | const tree = renderer.create().toJSON();
8 | expect(tree).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/frontend/src/components/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Segment } from 'semantic-ui-react';
3 |
4 | const Home = () => Welcome!;
5 |
6 | export default Home;
7 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Loading Components ComponentLoading should render correctly 1`] = `null`;
4 |
5 | exports[`Loading Components ComponentLoading should render correctly with error 1`] = `
6 |
9 | Error!
10 |
17 |
18 | `;
19 |
20 | exports[`Loading Components ComponentLoading should render correctly with pastDelay 1`] = `
21 |
24 | Loading...
25 |
26 | `;
27 |
28 | exports[`Loading Components ComponentLoading should render correctly with timedOut 1`] = `
29 |
32 | Taking a long time...
33 |
40 |
41 | `;
42 |
43 | exports[`Loading Components Loading should render correctly 1`] = `
44 |
48 |
51 |
54 | Loading
55 |
56 |
57 |
58 | `;
59 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import { Button } from 'semantic-ui-react';
5 | import Loading, { ComponentLoading } from './';
6 |
7 | describe('Loading Components', () => {
8 | describe('Loading', () => {
9 | test('should render correctly', () => {
10 | const tree = renderer.create().toJSON();
11 | expect(tree).toMatchSnapshot();
12 | });
13 | });
14 |
15 | describe('ComponentLoading', () => {
16 | const props = {
17 | error: null,
18 | isLoading: false,
19 | pastDelay: false,
20 | retry: jest.fn(),
21 | timedOut: false,
22 | };
23 |
24 | beforeEach(() => {
25 | jest.clearAllMocks();
26 | });
27 |
28 | test('should render correctly', () => {
29 | const tree = renderer.create().toJSON();
30 | expect(tree).toMatchSnapshot();
31 | });
32 |
33 | test('should render correctly with error', () => {
34 | const tree = renderer
35 | .create()
36 | .toJSON();
37 | expect(tree).toMatchSnapshot();
38 | });
39 |
40 | test('should render correctly with timedOut', () => {
41 | const tree = renderer
42 | .create()
43 | .toJSON();
44 | expect(tree).toMatchSnapshot();
45 | });
46 |
47 | test('should render correctly with pastDelay', () => {
48 | const tree = renderer
49 | .create()
50 | .toJSON();
51 | expect(tree).toMatchSnapshot();
52 | });
53 |
54 | test('should retry on click (error)', () => {
55 | const wrapper = shallow(
56 | ,
57 | );
58 |
59 | wrapper.find(Button).simulate('click');
60 |
61 | expect(props.retry).toHaveBeenCalledTimes(1);
62 | });
63 |
64 | test('should retry on click (timeout)', () => {
65 | const wrapper = shallow();
66 |
67 | wrapper.find(Button).simulate('click');
68 |
69 | expect(props.retry).toHaveBeenCalledTimes(1);
70 | });
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { LoadingComponentProps } from 'react-loadable';
3 | import { Button, Dimmer, Loader, Segment } from 'semantic-ui-react';
4 |
5 | export const Loading = () => (
6 |
7 | Loading
8 |
9 | );
10 |
11 | export const ComponentLoading = (props: LoadingComponentProps) => {
12 | if (props.error) {
13 | return (
14 |
15 | Error!
16 |
17 | );
18 | } else if (props.timedOut) {
19 | return (
20 |
21 | Taking a long time...
22 |
23 | );
24 | } else if (props.pastDelay) {
25 | return Loading...;
26 | } else {
27 | return null;
28 | }
29 | };
30 |
31 | export default Loading;
32 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Callback Component should render correctly 1`] = `
4 |
7 |
14 |
15 | `;
16 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { shallow } from 'enzyme';
2 | import * as React from 'react';
3 | import * as renderer from 'react-test-renderer';
4 | import configureStore from 'redux-mock-store';
5 | import { Button } from 'semantic-ui-react';
6 | import { loginRequested as login } from '../../actions/auth';
7 | import ConnectedLogin, { Login } from './';
8 |
9 | jest.mock('../../lib/auth');
10 |
11 | const middlewares: any[] = [];
12 | const mockStore = configureStore(middlewares);
13 |
14 | const props = {
15 | isAuthenticated: false,
16 | isAuthenticating: false,
17 | login: jest.fn(),
18 | redirectPath: '/',
19 | };
20 |
21 | describe('Callback Component', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | test('should render correctly', () => {
27 | const tree = renderer.create().toJSON();
28 | expect(tree).toMatchSnapshot();
29 | });
30 |
31 | test('invokes login on button click', async () => {
32 | const wrapper = shallow();
33 |
34 | wrapper.find(Button).simulate('click');
35 |
36 | expect(props.login).toHaveBeenCalledTimes(1);
37 | });
38 |
39 | test('should connect component correctly', () => {
40 | const store = mockStore();
41 |
42 | // @ts-ignore wrong store type
43 | const wrapper = shallow();
44 |
45 | wrapper.props().login();
46 |
47 | expect(store.getActions()).toHaveLength(1);
48 | expect(store.getActions()[0]).toEqual(login());
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { InjectedAuthReduxProps } from 'redux-auth-wrapper/history4/redirect';
4 | import { Button, Segment } from 'semantic-ui-react';
5 | import { loginRequested as login } from '../../actions/auth';
6 |
7 | interface IProps extends InjectedAuthReduxProps {
8 | login: typeof login;
9 | }
10 |
11 | export class Login extends React.Component {
12 | public render() {
13 | return (
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | private login = () => {
21 | this.props.login();
22 | };
23 | }
24 |
25 | export default connect(
26 | null,
27 | { login },
28 | )(Login);
29 |
--------------------------------------------------------------------------------
/frontend/src/components/NotFound/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`NotFound Component should render correctly 1`] = `
4 |
7 | Sorry, this page does not exist.
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/frontend/src/components/NotFound/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as renderer from 'react-test-renderer';
3 | import NotFound from './';
4 |
5 | describe('NotFound Component', () => {
6 | test('should render correctly', () => {
7 | const tree = renderer.create().toJSON();
8 | expect(tree).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/frontend/src/components/NotFound/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Segment } from 'semantic-ui-react';
3 |
4 | const NotFound = () => Sorry, this page does not exist.;
5 |
6 | export default NotFound;
7 |
--------------------------------------------------------------------------------
/frontend/src/components/Private/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Private Component should render correctly 1`] = `
4 |
7 | This is a private page
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/frontend/src/components/Private/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as renderer from 'react-test-renderer';
3 | import Private from './';
4 |
5 | describe('Private Component', () => {
6 | test('should render correctly', () => {
7 | const tree = renderer
8 | .create(
9 | ,
14 | )
15 | .toJSON();
16 | expect(tree).toMatchSnapshot();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/frontend/src/components/Private/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { InjectedAuthReduxProps } from 'redux-auth-wrapper/history4/redirect';
3 | import { Segment } from 'semantic-ui-react';
4 |
5 | export class Private extends React.Component {
6 | public render() {
7 | return This is a private page;
8 | }
9 | }
10 |
11 | export default Private;
12 |
--------------------------------------------------------------------------------
/frontend/src/components/Public/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Public Component should render correctly 1`] = `
4 |
7 | This is a public page
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/frontend/src/components/Public/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as renderer from 'react-test-renderer';
3 | import Public from './';
4 |
5 | describe('Public Component', () => {
6 | test('should render correctly', () => {
7 | const tree = renderer.create().toJSON();
8 | expect(tree).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/frontend/src/components/Public/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Segment } from 'semantic-ui-react';
3 |
4 | class Public extends React.Component {
5 | public render() {
6 | return This is a public page;
7 | }
8 | }
9 |
10 | export default Public;
11 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ConnectedRouter,
3 | connectRouter,
4 | routerMiddleware,
5 | } from 'connected-react-router';
6 | import { createBrowserHistory } from 'history';
7 | import * as React from 'react';
8 | import * as ReactDOM from 'react-dom';
9 | import { Provider } from 'react-redux';
10 | import { applyMiddleware, compose, createStore } from 'redux';
11 | import createSagaMiddleware from 'redux-saga';
12 | import 'semantic-ui-css/semantic.min.css';
13 | import App from './App';
14 | import './index.css';
15 | import rootReducer from './reducers';
16 | import registerServiceWorker from './registerServiceWorker';
17 | import sagas from './sagas';
18 |
19 | const devEnvironment =
20 | !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
21 |
22 | const history = createBrowserHistory();
23 |
24 | const sagaMiddleware = createSagaMiddleware();
25 |
26 | const applyAllMiddleware = () => {
27 | /* istanbul ignore if */
28 | if (devEnvironment) {
29 | const { createLogger } = require('redux-logger');
30 | const logger = createLogger();
31 | return applyMiddleware(sagaMiddleware, routerMiddleware(history), logger);
32 | } else {
33 | return applyMiddleware(sagaMiddleware, routerMiddleware(history));
34 | }
35 | };
36 |
37 | const __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ =
38 | // @ts-ignore: property does not exist
39 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
40 |
41 | /* istanbul ignore next */
42 | const loadExtension =
43 | devEnvironment &&
44 | typeof window === 'object' &&
45 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
46 |
47 | const composeEnhancers = loadExtension
48 | ? /* istanbul ignore next */
49 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
50 | : compose;
51 |
52 | const store = createStore(
53 | connectRouter(history)(rootReducer),
54 | composeEnhancers(applyAllMiddleware()),
55 | );
56 |
57 | sagaMiddleware.run(sagas);
58 |
59 | ReactDOM.render(
60 |
61 |
62 |
63 |
64 | ,
65 | document.getElementById('root') as HTMLElement,
66 | );
67 | registerServiceWorker();
68 |
--------------------------------------------------------------------------------
/frontend/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | const ENDPOINT = process.env.REACT_APP_API_SERVICE_ENDPOINT || '';
2 |
3 | const PUBLIC_ENDPOINT = `${ENDPOINT}/api/public`;
4 | const PRIVATE_ENDPOINT = `${ENDPOINT}/api/private`;
5 |
6 | const EMAIL_ENDPOINT = `${process.env.REACT_APP_EMAIL_SERVICE_ENDPOINT ||
7 | ''}/email`;
8 |
9 | const FILE_ENDPOINT = `${process.env.REACT_APP_FILE_SERVICE_ENDPOINT ||
10 | ''}/save`;
11 |
12 | const DB_ENDPOINT = `${process.env.REACT_APP_DB_SERVICE_ENDPOINT || ''}/todos`;
13 |
14 | export const checkStatus = (response: Response, message: string) => {
15 | if (response.ok) {
16 | return response;
17 | } else {
18 | const error = new Error(message);
19 | throw error;
20 | }
21 | };
22 |
23 | export const publicApi = async () => {
24 | const response = await fetch(PUBLIC_ENDPOINT, {
25 | cache: 'no-store',
26 | method: 'POST',
27 | });
28 |
29 | const { message } = await response.json();
30 | return message;
31 | };
32 |
33 | export const privateApi = async () => {
34 | const token = localStorage.getItem('id_token');
35 | const response = await fetch(PRIVATE_ENDPOINT, {
36 | headers: {
37 | Authorization: `Bearer ${token}`,
38 | },
39 | method: 'POST',
40 | });
41 |
42 | const { message } = await response.json();
43 | checkStatus(response, message);
44 |
45 | return message;
46 | };
47 |
48 | export const emailApi = async (toAddress: string) => {
49 | const token = localStorage.getItem('id_token');
50 | const response = await fetch(EMAIL_ENDPOINT, {
51 | body: JSON.stringify({ to_address: toAddress }),
52 | headers: {
53 | Authorization: `Bearer ${token}`,
54 | },
55 | method: 'POST',
56 | });
57 |
58 | const { message } = await response.json();
59 | checkStatus(response, message);
60 |
61 | return message;
62 | };
63 |
64 | interface IFileApiPayload {
65 | fileUrl: string;
66 | key: string;
67 | }
68 |
69 | export const saveFile = async ({ fileUrl, key }: IFileApiPayload) => {
70 | const response = await fetch(FILE_ENDPOINT, {
71 | body: JSON.stringify({ file_url: fileUrl, key }),
72 | method: 'POST',
73 | });
74 |
75 | const { message } = await response.json();
76 | checkStatus(response, message);
77 |
78 | return message;
79 | };
80 |
81 | export const createTodosItem = async (text: string) => {
82 | const response = await fetch(DB_ENDPOINT, {
83 | body: JSON.stringify({ text }),
84 | method: 'POST',
85 | });
86 |
87 | const item = await response.json();
88 | checkStatus(response, item);
89 |
90 | return JSON.stringify(item);
91 | };
92 |
--------------------------------------------------------------------------------
/frontend/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { Auth0DecodedHash, WebAuth } from 'auth0-js';
2 |
3 | const auth = new WebAuth({
4 | audience: process.env.REACT_APP_AUTH0_AUDIENCE,
5 | clientID: process.env.REACT_APP_AUTH0_CLIENT_ID || '',
6 | domain: process.env.REACT_APP_AUTH0_DOMAIM || '',
7 | redirectUri: process.env.REACT_APP_AUTH0_REDIRECT_URI,
8 | responseType: 'token id_token',
9 | scope: 'openid profile email',
10 | });
11 |
12 | const setSession = (authResult: Auth0DecodedHash) => {
13 | const { expiresIn = 0, accessToken = '', idToken = '' } = authResult;
14 | // Set the time that the access token will expire at
15 | const expiresAt = JSON.stringify(expiresIn * 1000 + new Date().getTime());
16 | localStorage.setItem('access_token', accessToken);
17 | localStorage.setItem('id_token', idToken);
18 | localStorage.setItem('expires_at', expiresAt);
19 | };
20 |
21 | const handleAuthentication = async () => {
22 | const promise: Promise = new Promise((resolve, reject) => {
23 | auth.parseHash((err, authResult) => {
24 | const result = authResult || {};
25 | if (err) {
26 | reject(err);
27 | } else {
28 | setSession(result);
29 | resolve(result);
30 | }
31 | });
32 | });
33 | return await promise;
34 | };
35 |
36 | const login = (state: string) => auth.authorize({ state });
37 |
38 | const logout = () => {
39 | localStorage.removeItem('access_token');
40 | localStorage.removeItem('id_token');
41 | localStorage.removeItem('expires_at');
42 | };
43 |
44 | const isAuthenticated = () => {
45 | // Check whether the current time is past the
46 | // access token's expiry time
47 | const expiresAt = JSON.parse(
48 | localStorage.getItem('expires_at') || JSON.stringify(0),
49 | );
50 | return new Date().getTime() < expiresAt;
51 | };
52 |
53 | export { handleAuthentication, login, logout, isAuthenticated };
54 |
--------------------------------------------------------------------------------
/frontend/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/reducers/api.test.ts:
--------------------------------------------------------------------------------
1 | import * as actions from '../actions/api';
2 | import * as types from '../actions/types';
3 | import reducer from './api';
4 |
5 | describe('api reducer', () => {
6 | const metaTypes = Object.keys(actions.apiMetaTypes);
7 |
8 | for (const metaType of metaTypes) {
9 | test(types.API_REQUESTED, () => {
10 | const action = actions.apiRequested(undefined, metaType);
11 |
12 | expect(
13 | reducer(
14 | {
15 | [metaType]: { inProgress: false, error: null },
16 | },
17 | action,
18 | )[metaType],
19 | ).toEqual({
20 | error: null,
21 | inProgress: true,
22 | result: null,
23 | });
24 | });
25 |
26 | test(types.API_FULFILLED, () => {
27 | const result = 'result';
28 | const action = actions.apiFulfilled(result, metaType);
29 |
30 | expect(
31 | reducer({ [metaType]: { inProgress: true, error: null } }, action)[
32 | metaType
33 | ],
34 | ).toEqual({
35 | error: null,
36 | inProgress: false,
37 | result,
38 | });
39 | });
40 |
41 | test(types.API_REJECTED, () => {
42 | const error = new Error('error');
43 | const action = actions.apiRejected(error, metaType);
44 |
45 | expect(
46 | reducer({ [metaType]: { inProgress: true, error: null } }, action)[
47 | metaType
48 | ],
49 | ).toEqual({
50 | error,
51 | inProgress: false,
52 | result: null,
53 | });
54 | });
55 | }
56 | });
57 |
--------------------------------------------------------------------------------
/frontend/src/reducers/api.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, Reducer, Action } from 'redux';
2 | import { handleActions } from 'redux-actions';
3 | import { filterActions } from 'redux-ignore';
4 | import * as actions from '../actions/api';
5 | import * as types from '../actions/types';
6 | import { IActionWithMeta, IApiState } from '../types/redux';
7 |
8 | export const predicate = (action: IActionWithMeta, metaType: string) => {
9 | if (action.meta && action.meta.type === metaType) {
10 | return true;
11 | }
12 | return false;
13 | };
14 |
15 | const initialState: IApiState = {
16 | error: null,
17 | inProgress: false,
18 | result: null,
19 | };
20 |
21 | const apiSubReducer = handleActions(
22 | {
23 | [types.API_REQUESTED]: () => {
24 | const newState = {
25 | error: null,
26 | inProgress: true,
27 | result: null,
28 | };
29 | return newState;
30 | },
31 | [types.API_FULFILLED]: (_, action) => {
32 | const newState = {
33 | error: null,
34 | inProgress: false,
35 | result: action.payload,
36 | };
37 | return newState;
38 | },
39 | [types.API_REJECTED]: (_, action) => {
40 | const newState = {
41 | error: action.payload,
42 | inProgress: false,
43 | result: null,
44 | };
45 | return newState;
46 | },
47 | },
48 | initialState,
49 | ) as Reducer;
50 |
51 | export let subReducers: {
52 | [metaType: string]: Reducer;
53 | } = {};
54 | Object.keys(actions.apiMetaTypes).forEach(metaType => {
55 | subReducers[metaType] = filterActions(apiSubReducer, (action: Action) =>
56 | predicate(action as IActionWithMeta, metaType),
57 | );
58 | });
59 |
60 | export default combineReducers(subReducers);
61 |
--------------------------------------------------------------------------------
/frontend/src/reducers/auth.test.ts:
--------------------------------------------------------------------------------
1 | import * as actions from '../actions/auth';
2 | import * as types from '../actions/types';
3 | import reducer from './auth';
4 |
5 | const state = {
6 | authenticated: false,
7 | error: null,
8 | inProgress: false,
9 | initialized: false,
10 | };
11 |
12 | describe('Auth Reducer', () => {
13 | test(types.LOGIN_REQUESTED, () => {
14 | expect(
15 | reducer({ ...state, inProgress: false }, actions.loginRequested()),
16 | ).toEqual({
17 | ...state,
18 | inProgress: true,
19 | });
20 | });
21 |
22 | test(types.LOGIN_FULFILLED, () => {
23 | expect(
24 | reducer(
25 | {
26 | ...state,
27 | authenticated: false,
28 | error: new Error('error'),
29 | inProgress: true,
30 | },
31 | actions.loginFulfilled(),
32 | ),
33 | ).toEqual({
34 | ...state,
35 | authenticated: true,
36 | error: null,
37 | inProgress: false,
38 | });
39 | });
40 |
41 | test(types.LOGIN_REJECTED, () => {
42 | const error = new Error('error');
43 | expect(
44 | reducer(
45 | {
46 | ...state,
47 | authenticated: true,
48 | error: null,
49 | inProgress: true,
50 | },
51 | actions.loginRejected(error),
52 | ),
53 | ).toEqual({
54 | ...state,
55 | authenticated: false,
56 | error,
57 | inProgress: false,
58 | });
59 | });
60 | test(types.LOGOUT_REQUESTED, () => {
61 | expect(
62 | reducer({ ...state, inProgress: false }, actions.logoutRequested()),
63 | ).toEqual({
64 | ...state,
65 | inProgress: true,
66 | });
67 | });
68 |
69 | test(types.LOGOUT_FULFILLED, () => {
70 | expect(
71 | reducer(
72 | {
73 | ...state,
74 | authenticated: true,
75 | error: new Error('error'),
76 | inProgress: true,
77 | },
78 | actions.logoutFulfilled(),
79 | ),
80 | ).toEqual({
81 | ...state,
82 | authenticated: false,
83 | error: null,
84 | inProgress: false,
85 | });
86 | });
87 |
88 | test(types.LOGOUT_FULFILLED, () => {
89 | const error = new Error('error');
90 | expect(
91 | reducer(
92 | {
93 | ...state,
94 | authenticated: true,
95 | error: null,
96 | inProgress: true,
97 | },
98 | actions.logoutRejected(error),
99 | ),
100 | ).toEqual({
101 | ...state,
102 | authenticated: false,
103 | error,
104 | inProgress: false,
105 | });
106 | });
107 |
108 | test(types.SYNC_AUTH_STATE_REQUESTED, () => {
109 | expect(
110 | reducer(
111 | { ...state, inProgress: false },
112 | actions.syncAuthStateRequested(),
113 | ),
114 | ).toEqual({
115 | ...state,
116 | inProgress: true,
117 | });
118 | });
119 |
120 | test(types.SYNC_AUTH_STATE_FULFILLED, () => {
121 | const authenticated = true;
122 | expect(
123 | reducer(
124 | {
125 | ...state,
126 | authenticated: false,
127 | error: new Error('error'),
128 | inProgress: true,
129 | initialized: false,
130 | },
131 | actions.syncAuthStateFulfilled(authenticated),
132 | ),
133 | ).toEqual({
134 | ...state,
135 | authenticated,
136 | error: null,
137 | inProgress: false,
138 | initialized: true,
139 | });
140 | });
141 |
142 | test(types.SYNC_AUTH_STATE_REJECTED, () => {
143 | const error = new Error('error');
144 | expect(
145 | reducer(
146 | {
147 | ...state,
148 | authenticated: true,
149 | error: null,
150 | inProgress: true,
151 | initialized: false,
152 | },
153 | actions.syncAuthStateRejected(error),
154 | ),
155 | ).toEqual({
156 | ...state,
157 | authenticated: false,
158 | error,
159 | inProgress: false,
160 | initialized: true,
161 | });
162 | });
163 | });
164 |
--------------------------------------------------------------------------------
/frontend/src/reducers/auth.ts:
--------------------------------------------------------------------------------
1 | import { Action, handleActions } from 'redux-actions';
2 | import * as types from '../actions/types';
3 | import { IAuthState } from '../types/redux';
4 |
5 | const defaultState: IAuthState = {
6 | authenticated: false,
7 | error: null,
8 | inProgress: false,
9 | initialized: false,
10 | };
11 |
12 | const reducer = handleActions(
13 | {
14 | [types.LOGIN_REQUESTED]: state => {
15 | return { ...state, inProgress: true };
16 | },
17 | [types.LOGIN_FULFILLED]: state => {
18 | return { ...state, inProgress: false, authenticated: true, error: null };
19 | },
20 | [types.LOGIN_REJECTED]: (state, action) => {
21 | return {
22 | ...state,
23 | authenticated: false,
24 | error: action.payload,
25 | inProgress: false,
26 | };
27 | },
28 | [types.LOGOUT_REQUESTED]: state => {
29 | return { ...state, inProgress: true };
30 | },
31 | [types.LOGOUT_FULFILLED]: state => {
32 | return { ...state, inProgress: false, authenticated: false, error: null };
33 | },
34 | [types.LOGOUT_REJECTED]: (state, action: Action) => {
35 | return {
36 | ...state,
37 | authenticated: false,
38 | error: action.payload,
39 | inProgress: false,
40 | };
41 | },
42 | [types.SYNC_AUTH_STATE_REQUESTED]: state => {
43 | return { ...state, inProgress: true };
44 | },
45 | [types.SYNC_AUTH_STATE_FULFILLED]: (_, action: Action) => {
46 | return {
47 | authenticated: !!action.payload,
48 | error: null,
49 | inProgress: false,
50 | initialized: true,
51 | };
52 | },
53 | [types.SYNC_AUTH_STATE_REJECTED]: (_, action: Action) => {
54 | return {
55 | authenticated: false,
56 | error: action.payload,
57 | inProgress: false,
58 | initialized: true,
59 | };
60 | },
61 | },
62 | defaultState,
63 | );
64 |
65 | export default reducer;
66 |
--------------------------------------------------------------------------------
/frontend/src/reducers/index.ts:
--------------------------------------------------------------------------------
1 | // istanbul ignore file
2 | import { combineReducers } from 'redux';
3 | import api from './api';
4 | import auth from './auth';
5 |
6 | const rootReducer = combineReducers({
7 | api,
8 | auth,
9 | });
10 |
11 | export default rootReducer;
12 |
--------------------------------------------------------------------------------
/frontend/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { push } from 'connected-react-router';
2 | import * as React from 'react';
3 | import Loadable from 'react-loadable';
4 | import { Route } from 'react-router';
5 | import locationHelperBuilder from 'redux-auth-wrapper/history4/locationHelper';
6 | import { connectedReduxRedirect } from 'redux-auth-wrapper/history4/redirect';
7 | import Loading from '../components/Loading';
8 | import { ComponentLoading } from '../components/Loading';
9 | import { isAuthenticated, isNotAuthenticated } from '../selectors/auth';
10 |
11 | export const paths = {
12 | callback: '/callback',
13 | home: '/',
14 | login: '/login',
15 | private: '/private',
16 | public: '/public',
17 | };
18 |
19 | export const userIsAuthenticated = connectedReduxRedirect({
20 | AuthenticatingComponent: Loading,
21 | // If selector is true, wrapper will not redirect
22 | // For example let's check that state contains user data
23 | authenticatedSelector: isAuthenticated,
24 | redirectAction: push,
25 | // The url to redirect user to if they fail
26 | redirectPath: paths.login,
27 | // A nice display name for this check
28 | wrapperDisplayName: 'UserIsAuthenticated',
29 | });
30 |
31 | const locationHelper = locationHelperBuilder({});
32 |
33 | export const userIsNotAuthenticated = connectedReduxRedirect({
34 | // This prevents us from adding the query parameter when we send the user away from the login page
35 | allowRedirectBack: false,
36 | // If selector is true, wrapper will not redirect
37 | // So if there is no user data, then we show the page
38 | authenticatedSelector: isNotAuthenticated,
39 | redirectAction: push,
40 | // This sends the user either to the query param route if we have one, or to the landing page if none is specified and the user is already logged in
41 | redirectPath: (_, ownProps) =>
42 | locationHelper.getRedirectQueryParam(ownProps) || paths.home,
43 | // A nice display name for this check
44 | wrapperDisplayName: 'UserIsNotAuthenticated',
45 | });
46 |
47 | export const routes = {
48 | Callback: (
49 | import('../components/Callback'),
53 | loading: ComponentLoading,
54 | })}
55 | />
56 | ),
57 | Home: (
58 | import('../components/Home'),
63 | loading: ComponentLoading,
64 | })}
65 | />
66 | ),
67 | Login: (
68 | import('../components/Login'),
73 | loading: ComponentLoading,
74 | }),
75 | )}
76 | />
77 | ),
78 | NotFound: (
79 | import('../components/NotFound'),
82 | loading: ComponentLoading,
83 | })}
84 | />
85 | ),
86 | Private: (
87 | import('../components/Private'),
92 | loading: ComponentLoading,
93 | }),
94 | )}
95 | />
96 | ),
97 | Public: (
98 | import('../components/Public'),
102 | loading: ComponentLoading,
103 | })}
104 | />
105 | ),
106 | };
107 |
--------------------------------------------------------------------------------
/frontend/src/sagas/api.test.ts:
--------------------------------------------------------------------------------
1 | import { call, put, takeEvery } from 'redux-saga/effects';
2 | import { cloneableGenerator } from 'redux-saga/utils';
3 | import * as actions from '../actions/api';
4 | import * as types from '../actions/types';
5 | import { emailApi, privateApi, publicApi } from '../lib/api';
6 | import * as sagas from './api';
7 |
8 | describe('api sagas', () => {
9 | test('getHandler', () => {
10 | expect(sagas.getHandler(actions.apiMetaTypes.email)).toBe(emailApi);
11 | expect(sagas.getHandler(actions.apiMetaTypes.private)).toBe(privateApi);
12 | expect(sagas.getHandler(actions.apiMetaTypes.public)).toBe(publicApi);
13 | });
14 |
15 | test('handleApi', () => {
16 | const toAddress = 'toAddress';
17 | const action = actions.emailApiRequested(toAddress);
18 | const generator = cloneableGenerator(sagas.handleApi)(action);
19 |
20 | expect(generator.next().value).toEqual(
21 | call(sagas.getHandler, action.meta.type),
22 | );
23 |
24 | const errorGenerator = generator.clone();
25 | const error = new Error('Some Error');
26 | expect(errorGenerator.throw && errorGenerator.throw(error).value).toEqual(
27 | put(actions.apiRejected(error, action.meta.type)),
28 | );
29 | expect(errorGenerator.next().done).toBe(true);
30 |
31 | const handler = jest.fn();
32 | expect(generator.next(handler).value).toEqual(call(handler, toAddress));
33 |
34 | const result = 'result';
35 | expect(generator.next(result).value).toEqual(
36 | put(actions.apiFulfilled(result, action.meta.type)),
37 | );
38 | expect(generator.next().done).toBe(true);
39 | });
40 |
41 | test('apiRequested', () => {
42 | const generator = sagas.apiRequested();
43 | expect(generator.next().value).toEqual(
44 | takeEvery(types.API_REQUESTED, sagas.handleApi),
45 | );
46 | expect(generator.next().done).toBe(true);
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/frontend/src/sagas/api.ts:
--------------------------------------------------------------------------------
1 | import { call, put, takeEvery } from 'redux-saga/effects';
2 | import * as actions from '../actions/api';
3 | import * as types from '../actions/types';
4 | import {
5 | createTodosItem,
6 | emailApi,
7 | privateApi,
8 | publicApi,
9 | saveFile,
10 | } from '../lib/api';
11 | import { IActionWithMeta } from '../types/redux';
12 |
13 | const handlers = {
14 | [actions.apiMetaTypes.email]: emailApi,
15 | [actions.apiMetaTypes.private]: privateApi,
16 | [actions.apiMetaTypes.public]: publicApi,
17 | [actions.apiMetaTypes.file]: saveFile,
18 | [actions.apiMetaTypes.dbCreate]: createTodosItem,
19 | };
20 |
21 | export const getHandler = (type: string) => {
22 | return handlers[type];
23 | };
24 |
25 | export function* handleApi(action: IActionWithMeta) {
26 | const { payload, meta } = action;
27 | const { type } = meta;
28 |
29 | try {
30 | const handler = yield call(getHandler, type);
31 | const result = yield call(handler, payload);
32 | yield put(actions.apiFulfilled(result, type));
33 | } catch (e) {
34 | yield put(actions.apiRejected(e, type));
35 | }
36 | }
37 |
38 | export function* apiRequested() {
39 | yield takeEvery(types.API_REQUESTED, handleApi);
40 | }
41 |
42 | const sagas = [apiRequested()];
43 |
44 | export default sagas;
45 |
--------------------------------------------------------------------------------
/frontend/src/sagas/auth.test.ts:
--------------------------------------------------------------------------------
1 | import { call, put, select, takeEvery } from 'redux-saga/effects';
2 | import { cloneableGenerator } from 'redux-saga/utils';
3 | import * as types from '../actions/types';
4 | import { isAuthenticated, login, logout } from '../lib/auth';
5 | import {
6 | logoutFulfilled,
7 | logoutRejected,
8 | syncAuthStateFulfilled,
9 | syncAuthStateRejected,
10 | } from './../actions/auth';
11 | import { redirectRouteSelector } from './../selectors/router';
12 | import * as sagas from './auth';
13 |
14 | describe('Auth Sagas', () => {
15 | test('should invoke login on handleLogin', () => {
16 | const generator = sagas.handleLogin();
17 | expect(generator.next().value).toEqual(select(redirectRouteSelector));
18 |
19 | const route = 'router';
20 | expect(generator.next(route).value).toEqual(call(login, route));
21 | expect(generator.next().done).toBe(true);
22 | });
23 |
24 | test('should handleLogin on loginSaga', () => {
25 | const generator = sagas.loginSaga();
26 | expect(generator.next().value).toEqual(
27 | takeEvery(types.LOGIN_REQUESTED, sagas.handleLogin),
28 | );
29 | expect(generator.next().done).toBe(true);
30 | });
31 |
32 | test('should invoke logout on handleLogout', () => {
33 | const generator = cloneableGenerator(sagas.handleLogout)();
34 |
35 | expect(generator.next().value).toEqual(call(logout));
36 |
37 | const errorGenerator = generator.clone();
38 | const error = new Error('logout error');
39 | expect(errorGenerator.throw && errorGenerator.throw(error).value).toEqual(
40 | put(logoutRejected(error)),
41 | );
42 | expect(errorGenerator.next().done).toBe(true);
43 |
44 | expect(generator.next().value).toEqual(put(logoutFulfilled()));
45 | expect(generator.next().done).toBe(true);
46 | });
47 |
48 | test('should handleLogout on logoutSaga', () => {
49 | const generator = sagas.logoutSaga();
50 | expect(generator.next().value).toEqual(
51 | takeEvery(types.LOGOUT_REQUESTED, sagas.handleLogout),
52 | );
53 | expect(generator.next().done).toBe(true);
54 | });
55 |
56 | test('should invoke isAuthenticated on handleAuthState', () => {
57 | const generator = cloneableGenerator(sagas.handleAuthState)();
58 |
59 | expect(generator.next().value).toEqual(call(isAuthenticated));
60 |
61 | const errorGenerator = generator.clone();
62 | const error = new Error('isAuthenticated error');
63 | expect(errorGenerator.throw && errorGenerator.throw(error).value).toEqual(
64 | put(syncAuthStateRejected(error)),
65 | );
66 | expect(errorGenerator.next().done).toBe(true);
67 |
68 | const authenticated = true;
69 | expect(generator.next(authenticated).value).toEqual(
70 | put(syncAuthStateFulfilled(authenticated)),
71 | );
72 | expect(generator.next().done).toBe(true);
73 | });
74 |
75 | test('should handleAuthState on authStateSaga', () => {
76 | const generator = sagas.authStateSaga();
77 | expect(generator.next().value).toEqual(
78 | takeEvery(types.SYNC_AUTH_STATE_REQUESTED, sagas.handleAuthState),
79 | );
80 | expect(generator.next().done).toBe(true);
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/frontend/src/sagas/auth.ts:
--------------------------------------------------------------------------------
1 | import { call, put, select, takeEvery } from 'redux-saga/effects';
2 | import * as types from '../actions/types';
3 | import { isAuthenticated, login, logout } from '../lib/auth';
4 | import {
5 | logoutFulfilled,
6 | logoutRejected,
7 | syncAuthStateFulfilled,
8 | syncAuthStateRejected,
9 | } from './../actions/auth';
10 | import { redirectRouteSelector } from './../selectors/router';
11 |
12 | export function* handleLogin() {
13 | const redirectRoute = yield select(redirectRouteSelector);
14 | yield call(login, redirectRoute);
15 | }
16 |
17 | export function* loginSaga() {
18 | yield takeEvery(types.LOGIN_REQUESTED, handleLogin);
19 | }
20 |
21 | export function* handleLogout() {
22 | try {
23 | yield call(logout);
24 | yield put(logoutFulfilled());
25 | } catch (err) {
26 | yield put(logoutRejected(err));
27 | }
28 | }
29 |
30 | export function* logoutSaga() {
31 | yield takeEvery(types.LOGOUT_REQUESTED, handleLogout);
32 | }
33 |
34 | export function* handleAuthState() {
35 | try {
36 | const authenticated = yield call(isAuthenticated);
37 | yield put(syncAuthStateFulfilled(authenticated));
38 | } catch (err) {
39 | yield put(syncAuthStateRejected(err));
40 | }
41 | }
42 |
43 | export function* authStateSaga() {
44 | yield takeEvery(types.SYNC_AUTH_STATE_REQUESTED, handleAuthState);
45 | }
46 |
47 | const sagas = [loginSaga(), logoutSaga(), authStateSaga()];
48 |
49 | export default sagas;
50 |
--------------------------------------------------------------------------------
/frontend/src/sagas/index.test.ts:
--------------------------------------------------------------------------------
1 | import { all } from 'redux-saga/effects';
2 | import root from './index';
3 |
4 | jest.mock('./api', () => ['api']);
5 |
6 | jest.mock('./auth', () => ['auth']);
7 |
8 | describe('Sagas Index', () => {
9 | test('should export all sagas', () => {
10 | const generator = root();
11 | expect(generator.next().value).toEqual(all(['api', 'auth']));
12 | expect(generator.next().done).toBe(true);
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/frontend/src/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { all } from 'redux-saga/effects';
2 | import api from './api';
3 | import auth from './auth';
4 |
5 | export const sagas = [...api, ...auth];
6 |
7 | export default function* root() {
8 | yield all(sagas);
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/selectors/api.test.ts:
--------------------------------------------------------------------------------
1 | import { apiMetaTypes } from './../actions/api';
2 | import * as selectors from './api';
3 |
4 | describe('Api Selectors', () => {
5 | test('should return api state on apiStateSelector', () => {
6 | const subState = { inProgress: false, error: null, result: 'result' };
7 | expect(
8 | // @ts-ignore missing properties for state
9 | selectors.apiStateSelector({ api: { apiType: subState } }, 'apiType'),
10 | ).toEqual(subState);
11 | });
12 |
13 | Object.keys(apiMetaTypes).forEach(key => {
14 | test(`should return ${key} state on selector`, () => {
15 | const subState = { inProgress: false, error: null, result: 'result' };
16 | expect(
17 | // @ts-ignore missing properties for state
18 | selectors[`${key}ApiSelector`]({ api: { [key]: subState } }),
19 | ).toEqual(subState);
20 | });
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/frontend/src/selectors/api.ts:
--------------------------------------------------------------------------------
1 | import { apiMetaTypes } from '../actions/api';
2 | import { IState } from '../types/redux';
3 |
4 | export const apiStateSelector = (state: IState, type: string) =>
5 | state.api[type];
6 |
7 | export const emailApiSelector = (state: IState) =>
8 | apiStateSelector(state, apiMetaTypes.email);
9 |
10 | export const privateApiSelector = (state: IState) =>
11 | apiStateSelector(state, apiMetaTypes.private);
12 |
13 | export const publicApiSelector = (state: IState) =>
14 | apiStateSelector(state, apiMetaTypes.public);
15 |
16 | export const fileApiSelector = (state: IState) =>
17 | apiStateSelector(state, apiMetaTypes.file);
18 |
19 | export const dbCreateApiSelector = (state: IState) =>
20 | apiStateSelector(state, apiMetaTypes.dbCreate);
21 |
--------------------------------------------------------------------------------
/frontend/src/selectors/auth.test.ts:
--------------------------------------------------------------------------------
1 | import * as selectors from './auth';
2 |
3 | describe('Auth Selectors', () => {
4 | test('should return auth flag on isAuthenticated', () => {
5 | expect(
6 | // @ts-ignore missing properties for state
7 | selectors.isAuthenticated({ auth: { authenticated: false } }),
8 | ).toEqual(false);
9 | expect(
10 | // @ts-ignore missing properties for state
11 | selectors.isAuthenticated({ auth: { authenticated: true } }),
12 | ).toEqual(true);
13 | });
14 |
15 | test('should negate auth flag on isNotAuthenticated', () => {
16 | expect(
17 | // @ts-ignore missing properties for state
18 | selectors.isNotAuthenticated({ auth: { authenticated: false } }),
19 | ).toEqual(true);
20 | expect(
21 | // @ts-ignore missing properties for state
22 | selectors.isNotAuthenticated({ auth: { authenticated: true } }),
23 | ).toEqual(false);
24 | });
25 |
26 | test('should return initialized flag on isInitialized', () => {
27 | expect(
28 | // @ts-ignore missing properties for state
29 | selectors.isInitialized({ auth: { initialized: false } }),
30 | ).toEqual(false);
31 | expect(
32 | // @ts-ignore missing properties for state
33 | selectors.isInitialized({ auth: { initialized: true } }),
34 | ).toEqual(true);
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/frontend/src/selectors/auth.ts:
--------------------------------------------------------------------------------
1 | import { IState } from '../types/redux';
2 |
3 | export const isAuthenticated = (state: IState) => state.auth.authenticated;
4 | export const isNotAuthenticated = (state: IState) => !state.auth.authenticated;
5 | export const isInitialized = (state: IState) => state.auth.initialized;
6 |
--------------------------------------------------------------------------------
/frontend/src/selectors/router.test.ts:
--------------------------------------------------------------------------------
1 | import { paths } from '../routes';
2 | import * as selectors from './router';
3 |
4 | describe('Router Selectors', () => {
5 | test('should return pathname', () => {
6 | expect(
7 | // @ts-ignore missing properties for state
8 | selectors.pathnameSelector({ router: { location: { pathname: '' } } }),
9 | ).toEqual(paths.home);
10 | expect(
11 | selectors.pathnameSelector({
12 | // @ts-ignore missing properties for state
13 | router: { location: { pathname: '/somepath' } },
14 | }),
15 | ).toEqual('/somepath');
16 | });
17 |
18 | test('should return redirect query parameter', () => {
19 | expect(
20 | selectors.redirectRouteSelector({
21 | // @ts-ignore missing properties for state
22 | router: { location: { search: '?' } },
23 | }),
24 | ).toEqual(null);
25 | expect(
26 | selectors.redirectRouteSelector({
27 | // @ts-ignore missing properties for state
28 | router: { location: { search: '?redirect=login' } },
29 | }),
30 | ).toEqual('login');
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/frontend/src/selectors/router.ts:
--------------------------------------------------------------------------------
1 | import { paths } from '../routes';
2 | import { IState } from '../types/redux';
3 |
4 | export const pathnameSelector = (state: IState) =>
5 | state.router.location.pathname || paths.home;
6 |
7 | export const redirectRouteSelector = (state: IState) => {
8 | const search = state.router.location.search;
9 | const params = new URLSearchParams(search);
10 | return params.get('redirect');
11 | };
12 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | import Enzyme from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 | import 'jest-styled-components';
4 |
5 | Enzyme.configure({ adapter: new Adapter() });
6 |
--------------------------------------------------------------------------------
/frontend/src/types/redux.ts:
--------------------------------------------------------------------------------
1 | import { RouterState } from 'connected-react-router';
2 | import { ActionMeta } from 'redux-actions';
3 |
4 | interface IMeta {
5 | type: string;
6 | }
7 |
8 | export interface IActionWithMeta extends ActionMeta {}
9 |
10 | export interface IAuthState {
11 | authenticated: boolean;
12 | error: Error | null;
13 | inProgress: boolean;
14 | initialized: boolean;
15 | }
16 |
17 | export interface IRouterState extends RouterState {
18 | location: {
19 | pathname: string;
20 | search: string;
21 | hash: string;
22 | key: string;
23 | state: { from: string };
24 | };
25 | }
26 |
27 | export interface IApiState {
28 | inProgress: boolean;
29 | result: any;
30 | error: Error | null;
31 | }
32 |
33 | export interface IState {
34 | auth: IAuthState;
35 | router: IRouterState;
36 | api: { [key: string]: IApiState };
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "preserve"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
3 | "linterOptions": {
4 | "exclude": ["config/**/*.js", "node_modules/**/*.ts"]
5 | },
6 | "rules": {
7 | "no-unused-expression": [true, "allow-fast-null-checks"]
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "23.2.0",
3 | "npmClient": "yarn",
4 | "useWorkspaces": true,
5 | "packages": [
6 | "services/*",
7 | "frontend"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "workspaces": [
4 | "services/*",
5 | "frontend"
6 | ],
7 | "scripts": {
8 | "build:frontend": "lerna run build --scope frontend",
9 | "lint": "lerna exec yarn lint",
10 | "test": "lerna exec yarn test",
11 | "test:ci": "lerna exec --concurrency 1 yarn test:ci",
12 | "test:e2e": "lerna run --concurrency 1 test:e2e",
13 | "coverage": "lerna exec --concurrency 1 yarn coverage",
14 | "deploy:dev": "lerna run --concurrency 1 deploy --ignore 'frontend' -- --stage dev && lerna run deploy --scope frontend -- --stage dev",
15 | "deploy:qa": "lerna run --concurrency 1 deploy --ignore 'frontend' -- --stage qa && lerna run deploy --scope frontend -- --stage qa",
16 | "deploy:prod": "lerna run --concurrency 1 deploy --ignore 'frontend' -- --stage prod && lerna run deploy --scope frontend -- --stage prod",
17 | "publish:frontend:dev": "lerna run publish --scope frontend -- --stage dev",
18 | "publish:frontend:qa": "lerna run publish --scope frontend -- --stage qa",
19 | "publish:frontend:prod": "lerna run publish --scope frontend -- --stage prod",
20 | "run:all:dev": "yarn deploy:dev && yarn build:frontend && yarn publish:frontend:dev",
21 | "run:all:qa": "yarn deploy:qa && yarn build:frontend && yarn publish:frontend:qa",
22 | "run:all:prod": "yarn deploy:prod && yarn build:frontend && yarn publish:frontend:prod",
23 | "remove:all:dev": "lerna run --concurrency 1 remove --scope 'frontend' -- --stage dev && lerna run --concurrency 1 remove --ignore 'frontend' -- --stage dev",
24 | "remove:all:qa": "lerna run --concurrency 1 remove --scope 'frontend' -- --stage qa && lerna run --concurrency 1 remove --ignore 'frontend' -- --stage qa",
25 | "remove:all:prod": "lerna run --concurrency 1 remove --scope 'frontend' -- --stage prod && lerna run --concurrency 1 remove --ignore 'frontend' -- --stage prod",
26 | "prettier": "lerna exec yarn prettier",
27 | "prettier:ci": "lerna exec yarn prettier:ci"
28 | },
29 | "devDependencies": {
30 | "jest-junit": "^13.0.0",
31 | "lerna": "^4.0.0",
32 | "prettier": "^2.0.0"
33 | },
34 | "author": "Erez Rokah",
35 | "license": "MIT",
36 | "name": "serverless-monorepo-app",
37 | "resolutions": {
38 | "@types/react": "17.0.86"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["github>erezrokah/shared-configurations:renovate-default"]
3 | }
4 |
--------------------------------------------------------------------------------
/scripts/stackOutput.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const fs = require('fs-extra');
3 | const dotenv = require('dotenv');
4 | const os = require('os');
5 |
6 | const replaceInEnvFile = async (file, envs) => {
7 | const keys = Object.keys(envs);
8 | if (keys.length <= 0) {
9 | return;
10 | }
11 |
12 | const envFile = path.join(__dirname, '../frontend', file);
13 | await fs.ensureFile(envFile);
14 | const content = await fs.readFile(envFile);
15 | const envConfig = await dotenv.parse(content);
16 |
17 | keys.forEach(key => {
18 | envConfig[key] = envs[key];
19 | });
20 |
21 | await fs.remove(envFile);
22 | await Promise.all(
23 | Object.keys(envConfig).map(key =>
24 | fs.appendFile(envFile, `${key}=${envConfig[key]}${os.EOL}`),
25 | ),
26 | );
27 | };
28 |
29 | const handler = async (data, serverless, _) => {
30 | //this handler creates the environment for the frontend based on the services deployment output
31 | const { ServiceEndpoint, WebAppCloudFrontDistributionOutput } = data;
32 |
33 | if (WebAppCloudFrontDistributionOutput) {
34 | await replaceInEnvFile('.env.production.local', {
35 | REACT_APP_AUTH0_REDIRECT_URI: `https://${WebAppCloudFrontDistributionOutput}/callback`,
36 | });
37 | }
38 |
39 | if (ServiceEndpoint) {
40 | const serviceName = serverless.service.serviceObject.name
41 | .replace(/\-/g, '_')
42 | .toUpperCase();
43 |
44 | await replaceInEnvFile('.env.local', {
45 | [`REACT_APP_${serviceName}_ENDPOINT`]: ServiceEndpoint,
46 | });
47 | }
48 | };
49 |
50 | module.exports = { handler };
51 |
--------------------------------------------------------------------------------
/services/api-service/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 |
8 | # Webpack directories
9 | .webpack
10 |
11 | config.*.json
12 | !config.example.json
13 |
14 | coverage
15 |
16 | junit.xml
17 |
18 | out.txt
19 |
20 | e2e/config.ts
--------------------------------------------------------------------------------
/services/api-service/README.md:
--------------------------------------------------------------------------------
1 | # Api Service
2 |
3 | ## Setup
4 |
5 | ```bash
6 | yarn install
7 | ```
8 |
--------------------------------------------------------------------------------
/services/api-service/e2e/config.example.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | api: 'https://api-id.execute-api.us-east-1.amazonaws.com/dev/api',
3 | clientId: '*************************',
4 | domain: 'my-domain.auth0.com',
5 | email: 'test@email.com',
6 | managementClientId: '*************************',
7 | managementClientSecret: '*************************',
8 | password: '*******************',
9 | redirectUri: 'http://localhost:5000/callback',
10 | };
11 |
--------------------------------------------------------------------------------
/services/api-service/e2e/privateEndpoint.test.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { config } from './config';
3 | import { deleteUserByEmail } from './utils/auth0';
4 | import { auth0Login } from './utils/puppeteer';
5 |
6 | describe('private api e2e', () => {
7 | const url = `${config.api}/private`;
8 | const method = 'POST';
9 |
10 | describe('unauthorized tests', () => {
11 | test('should return 401 response', async () => {
12 | expect.assertions(1);
13 | await expect({ url, method }).toReturnResponse({
14 | data: {
15 | message: 'Unauthorized',
16 | },
17 | statusCode: 401,
18 | });
19 | });
20 | });
21 |
22 | describe('authorized tests', () => {
23 | const {
24 | domain,
25 | clientId,
26 | email,
27 | password,
28 | redirectUri,
29 | managementClientId,
30 | managementClientSecret,
31 | } = config;
32 |
33 | let authorization = '';
34 |
35 | beforeEach(async () => {
36 | // delete existing test user
37 | await deleteUserByEmail(
38 | email,
39 | domain,
40 | managementClientId,
41 | managementClientSecret,
42 | );
43 |
44 | // login and get token
45 | authorization = await auth0Login(
46 | domain,
47 | clientId,
48 | email,
49 | password,
50 | redirectUri,
51 | );
52 | });
53 |
54 | afterEach(async () => {
55 | // delete existing test user
56 | await deleteUserByEmail(
57 | email,
58 | domain,
59 | managementClientId,
60 | managementClientSecret,
61 | );
62 | });
63 |
64 | test('should return 200 response on authorized request', async () => {
65 | expect.assertions(1);
66 |
67 | await expect({
68 | headers: { Authorization: authorization },
69 | method,
70 | url,
71 | }).toReturnResponse({
72 | data: {
73 | message:
74 | 'Hi ⊂◉‿◉つ from Private API. Only logged in users can see this',
75 | },
76 | statusCode: 200,
77 | });
78 | });
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/services/api-service/e2e/publicEndpoint.chai.test.ts:
--------------------------------------------------------------------------------
1 | import awsTesting from 'aws-testing-library/lib/chai';
2 | import chai = require('chai');
3 |
4 | chai.use(awsTesting);
5 |
6 | const { expect } = chai;
7 |
8 | describe('public api e2e', () => {
9 | const { config } = require('./config');
10 | const url = `${config.api}/public`;
11 | const method = 'POST';
12 |
13 | test('should return 200OK response', async () => {
14 | await expect({ url, method }).to.have.response({
15 | data: {
16 | message: 'Hi ⊂◉‿◉つ from Public API',
17 | },
18 | statusCode: 200,
19 | });
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/services/api-service/e2e/publicEndpoint.test.ts:
--------------------------------------------------------------------------------
1 | describe('public api e2e', () => {
2 | const { config } = require('./config');
3 | const url = `${config.api}/public`;
4 | const method = 'POST';
5 |
6 | test('should return 200OK response', async () => {
7 | expect.assertions(1);
8 | await expect({ url, method }).toReturnResponse({
9 | data: {
10 | message: 'Hi ⊂◉‿◉つ from Public API',
11 | },
12 | statusCode: 200,
13 | });
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/services/api-service/e2e/setup.ts:
--------------------------------------------------------------------------------
1 | import { deploy } from 'aws-testing-library/lib/utils/serverless';
2 |
3 | const deployDev = async () => {
4 | await deploy('dev');
5 | };
6 |
7 | export default deployDev;
8 |
--------------------------------------------------------------------------------
/services/api-service/e2e/setupFrameworks.ts:
--------------------------------------------------------------------------------
1 | import 'aws-testing-library/lib/jest';
2 |
--------------------------------------------------------------------------------
/services/api-service/e2e/setup_hook.js:
--------------------------------------------------------------------------------
1 | require('ts-node/register');
2 | module.exports = require('./setup').default;
3 |
--------------------------------------------------------------------------------
/services/api-service/e2e/utils/auth0.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export interface IConfig {
4 | domain: string;
5 | clientId: string;
6 | clientSecret: string;
7 | }
8 |
9 | interface IUser {
10 | email: string;
11 | user_id: string;
12 | }
13 |
14 | const getAuthorization = async (
15 | domain: string,
16 | clientId: string,
17 | clientSecret: string,
18 | ) => {
19 | const response = await axios({
20 | data: JSON.stringify({
21 | audience: `https://${domain}/api/v2/`,
22 | client_id: clientId,
23 | client_secret: clientSecret,
24 | grant_type: 'client_credentials',
25 | }),
26 | headers: { 'content-type': 'application/json' },
27 | method: 'POST',
28 | url: `https://${domain}/oauth/token`,
29 | });
30 | const { token_type, access_token } = response.data;
31 | return `${token_type} ${access_token}`;
32 | };
33 |
34 | const getUsers = async (authorization: string, domain: string) => {
35 | const response = await axios({
36 | headers: { Authorization: authorization },
37 | method: 'GET',
38 | url: `https://${domain}/api/v2/users`,
39 | });
40 | return response.data as IUser[];
41 | };
42 |
43 | const deleteUser = async (
44 | authorization: string,
45 | domain: string,
46 | user: IUser,
47 | ) => {
48 | await axios({
49 | headers: { Authorization: authorization },
50 | method: 'DELETE',
51 | url: `https://${domain}/api/v2/users/${user.user_id}`,
52 | });
53 | };
54 |
55 | export const deleteUserByEmail = async (
56 | email: string,
57 | domain: string,
58 | clientId: string,
59 | clientSecret: string,
60 | ) => {
61 | const authorization = await getAuthorization(domain, clientId, clientSecret);
62 | const users = await getUsers(authorization, domain);
63 | // https://community.auth0.com/t/creating-a-user-converts-email-to-lowercase/6678
64 | const toDelete = users.filter(u => u.email === email.toLowerCase())[0];
65 | if (toDelete) {
66 | await deleteUser(authorization, domain, toDelete);
67 | }
68 | };
69 |
--------------------------------------------------------------------------------
/services/api-service/e2e/utils/puppeteer.ts:
--------------------------------------------------------------------------------
1 | import * as puppeteer from 'puppeteer';
2 | import { URLSearchParams } from 'url';
3 |
4 | const createNonce = (length: number) => {
5 | let text = '';
6 | const possible =
7 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
8 | for (let i = 0; i < length; i++) {
9 | text += possible.charAt(Math.floor(Math.random() * possible.length));
10 | }
11 | return text;
12 | };
13 |
14 | export const auth0Login = async (
15 | domain: string,
16 | clientId: string,
17 | email: string,
18 | password: string,
19 | redirectUri: string,
20 | ) => {
21 | const browser = await puppeteer.launch();
22 |
23 | const [page] = await browser.pages();
24 |
25 | // urls hold redirect chain
26 | const urls = [] as string[];
27 |
28 | // Get all redirects
29 | page.on('response', response => {
30 | const status = response.status();
31 | if (status >= 300 && status <= 399) {
32 | urls.push(response.headers()['location']);
33 | }
34 | });
35 |
36 | const nonce = createNonce(32);
37 |
38 | await page.goto(
39 | `https://${domain}/authorize?client_id=${clientId}&response_type=id_token token&nonce=${nonce}&redirect_uri=${redirectUri}`,
40 | { waitUntil: 'networkidle2' },
41 | );
42 |
43 | await page.waitForXPath("//a[contains(text(), 'Sign Up')]", {
44 | timeout: 5000,
45 | visible: true,
46 | });
47 | const signUp = await page.$x("//a[contains(text(), 'Sign Up')]");
48 | await signUp[0].click();
49 |
50 | await page.waitForSelector('input[name="email"]', {
51 | timeout: 5000,
52 | visible: true,
53 | });
54 | await page.type('input[name="email"]', email);
55 | await page.type('input[name="password"]', password);
56 | await page.click('button[type="submit"]');
57 |
58 | await page.waitForSelector('button[id="allow"]', {
59 | timeout: 5000,
60 | visible: true,
61 | });
62 | await page.click('button[id="allow"]');
63 | await page.waitForNavigation({ waitUntil: 'networkidle2' });
64 |
65 | const clientUrl = urls.filter(u => u.startsWith(redirectUri))[0];
66 | let token = '';
67 | if (clientUrl) {
68 | const hashQuery = clientUrl.substring(
69 | redirectUri.length + '/'.length + '#'.length,
70 | );
71 | const params = new URLSearchParams(hashQuery);
72 | token = params.get('id_token') || '';
73 | console.log('Login success, token:', token);
74 | } else {
75 | console.error('Login failed. Current url:', clientUrl);
76 | }
77 |
78 | await browser.close();
79 | return `Bearer ${token}`;
80 | };
81 |
--------------------------------------------------------------------------------
/services/api-service/jest.config.e2e.js:
--------------------------------------------------------------------------------
1 | const config = require('./jest.config');
2 |
3 | module.exports = {
4 | ...config,
5 | roots: ['/e2e'],
6 | globalSetup: '/e2e/setup_hook.js',
7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts',
8 | };
9 |
--------------------------------------------------------------------------------
/services/api-service/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8 | reporters: ['default', 'jest-junit'],
9 | testEnvironment: 'node',
10 | };
11 |
--------------------------------------------------------------------------------
/services/api-service/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "api-service",
3 | "version": "0.0.1",
4 | "description": "Serverless Auth0 Service",
5 | "scripts": {
6 | "lint": "tslint 'src/**/*.ts'",
7 | "test": "jest",
8 | "test:watch": "jest --watch",
9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci",
10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci",
11 | "coverage": "jest --coverage",
12 | "deploy": "serverless deploy",
13 | "remove": "serverless remove",
14 | "prettier": "prettier --write src/**/*.ts",
15 | "prettier:ci": "prettier --list-different src/**/*.ts"
16 | },
17 | "dependencies": {
18 | "jsonwebtoken": "^8.5.1"
19 | },
20 | "devDependencies": {
21 | "@anttiviljami/serverless-stack-output": "^0.3.1",
22 | "@types/aws-lambda": "^8.10.62",
23 | "@types/chai": "^4.2.12",
24 | "@types/jest": "^27.0.0",
25 | "@types/jsonwebtoken": "^8.5.0",
26 | "@types/node": "^16.0.0",
27 | "@types/puppeteer": "^5.0.0",
28 | "aws-testing-library": "^2.0.0",
29 | "axios": "^1.0.0",
30 | "chai": "^4.2.0",
31 | "jest": "27.5.1",
32 | "puppeteer": "^13.0.0",
33 | "serverless-plugin-tracing": "^2.0.0",
34 | "serverless-webpack": "^5.2.0",
35 | "ts-jest": "27.1.5",
36 | "ts-loader": "^9.0.0",
37 | "tslint": "^6.0.0",
38 | "tslint-config-prettier": "^1.18.0",
39 | "typescript": "^4.0.0",
40 | "webpack": "^5.0.0",
41 | "webpack-node-externals": "^3.0.0"
42 | },
43 | "author": "Erez Rokah",
44 | "license": "MIT"
45 | }
46 |
--------------------------------------------------------------------------------
/services/api-service/serverless.yml:
--------------------------------------------------------------------------------
1 | service: api-service
2 |
3 | plugins:
4 | - serverless-webpack
5 | - '@anttiviljami/serverless-stack-output'
6 | - serverless-plugin-tracing
7 |
8 | custom:
9 | defaultStage: dev
10 | currentStage: ${opt:stage, self:custom.defaultStage}
11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region}
12 |
13 | output:
14 | handler: ../../scripts/stackOutput.handler
15 |
16 | webpack:
17 | includeModules: true
18 | packager: yarn
19 |
20 | provider:
21 | name: aws
22 | runtime: nodejs8.10
23 | stage: ${self:custom.currentStage}
24 | region: ${self:custom.currentRegion}
25 |
26 | memorySize: 256
27 | logRetentionInDays: 7
28 |
29 | tracing: true # enable tracing
30 | iamRoleStatements:
31 | - Effect: 'Allow' # xray permissions (required)
32 | Action:
33 | - 'xray:PutTraceSegments'
34 | - 'xray:PutTelemetryRecords'
35 | Resource:
36 | - '*'
37 |
38 | environment:
39 | AUTH0_CLIENT_ID: ${file(../common/environment/config.${self:custom.currentStage}.json):AUTH0_CLIENT_ID}
40 | AUTH0_CLIENT_PUBLIC_KEY: ${file(../common/environment/public_key.${self:custom.currentStage}.pem)}
41 |
42 | functions:
43 | auth:
44 | handler: src/authHandler.auth
45 | cors: true
46 |
47 | publicEndpoint:
48 | handler: src/publicEndpointHandler.publicEndpoint
49 | events:
50 | - http:
51 | path: api/public
52 | method: post
53 | cors: true
54 |
55 | privateEndpoint:
56 | handler: src/privateEndpointHandler.privateEndpoint
57 | events:
58 | - http:
59 | path: api/private
60 | method: post
61 | authorizer: auth
62 | cors: true
63 |
64 | resources:
65 | Resources:
66 | # This response is needed for custom authorizer failures cors support ¯\_(ツ)_/¯
67 | GatewayResponse:
68 | Type: 'AWS::ApiGateway::GatewayResponse'
69 | Properties:
70 | ResponseParameters:
71 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
72 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
73 | ResponseType: EXPIRED_TOKEN
74 | RestApiId:
75 | Ref: 'ApiGatewayRestApi'
76 | StatusCode: '401'
77 | AuthFailureGatewayResponse:
78 | Type: 'AWS::ApiGateway::GatewayResponse'
79 | Properties:
80 | ResponseParameters:
81 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
82 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
83 | ResponseType: UNAUTHORIZED
84 | RestApiId:
85 | Ref: 'ApiGatewayRestApi'
86 | StatusCode: '401'
87 |
88 | package:
89 | individually: true
90 | exclude:
91 | - coverage/**
92 | - .circleci/**
93 |
--------------------------------------------------------------------------------
/services/api-service/src/authHandler.test.ts:
--------------------------------------------------------------------------------
1 | import { auth } from './authHandler';
2 |
3 | jest.mock('../../common/src/auth/authenticator');
4 |
5 | describe('auth handler', () => {
6 | const { decodeToken } = require('../../common/src/auth/authenticator');
7 | const response = {};
8 | const error = new Error('some error');
9 |
10 | beforeEach(() => {
11 | jest.clearAllMocks();
12 | });
13 |
14 | test('should call callback on response', async () => {
15 | decodeToken.mockReturnValue(Promise.resolve({ response, error: null }));
16 |
17 | const event = {
18 | authorizationToken: 'authorizationToken',
19 | methodArn: 'methodArn',
20 | };
21 | const context: any = null;
22 | const callback = jest.fn();
23 |
24 | await auth(event, context, callback);
25 |
26 | expect(decodeToken).toHaveBeenCalledTimes(1);
27 | expect(decodeToken).toHaveBeenCalledWith(
28 | event.authorizationToken,
29 | event.methodArn,
30 | );
31 | expect(callback).toHaveBeenCalledTimes(1);
32 | expect(callback).toHaveBeenCalledWith(null, response);
33 | });
34 |
35 | test('should call callback on error', async () => {
36 | decodeToken.mockReturnValue(Promise.resolve({ response, error }));
37 |
38 | const event = {
39 | authorizationToken: 'authorizationToken',
40 | methodArn: 'methodArn',
41 | };
42 | const context: any = null;
43 | const callback = jest.fn();
44 |
45 | await auth(event, context, callback);
46 |
47 | expect(decodeToken).toHaveBeenCalledTimes(1);
48 | expect(decodeToken).toHaveBeenCalledWith(
49 | event.authorizationToken,
50 | event.methodArn,
51 | );
52 | expect(callback).toHaveBeenCalledTimes(1);
53 | expect(callback).toHaveBeenCalledWith(error);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/services/api-service/src/authHandler.ts:
--------------------------------------------------------------------------------
1 | import { Callback, Context, CustomAuthorizerEvent, Handler } from 'aws-lambda';
2 | import { decodeToken } from '../../common/src/auth/authenticator';
3 |
4 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml
5 | export const auth: Handler = (
6 | event: CustomAuthorizerEvent,
7 | context: Context,
8 | callback: Callback,
9 | ) => {
10 | console.log(event);
11 | return decodeToken(event.authorizationToken, event.methodArn).then(
12 | ({ response, error }) => {
13 | if (error) {
14 | callback && callback(error);
15 | } else {
16 | callback && callback(null, response);
17 | }
18 | },
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/services/api-service/src/privateEndpointHandler.test.ts:
--------------------------------------------------------------------------------
1 | import { privateEndpoint } from './privateEndpointHandler';
2 |
3 | describe('private endpoint handler', () => {
4 | beforeEach(() => {
5 | jest.clearAllMocks();
6 | });
7 |
8 | test('should call callback on event', () => {
9 | const event = {};
10 | const context: any = null;
11 | const callback = jest.fn();
12 |
13 | privateEndpoint(event, context, callback);
14 |
15 | expect(callback).toHaveBeenCalledTimes(1);
16 | expect(callback).toHaveBeenCalledWith(null, {
17 | body: JSON.stringify({
18 | message:
19 | 'Hi ⊂◉‿◉つ from Private API. Only logged in users can see this',
20 | }),
21 | headers: {
22 | /* Required for cookies, authorization headers with HTTPS */
23 | 'Access-Control-Allow-Credentials': true,
24 | /* Required for CORS support to work */
25 | 'Access-Control-Allow-Origin': '*',
26 | },
27 | statusCode: 200,
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/services/api-service/src/privateEndpointHandler.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
2 |
3 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml
4 | export const privateEndpoint: Handler = (
5 | event: APIGatewayEvent,
6 | context: Context,
7 | callback: Callback,
8 | ) => {
9 | callback(null, {
10 | body: JSON.stringify({
11 | message: 'Hi ⊂◉‿◉つ from Private API. Only logged in users can see this',
12 | }),
13 | headers: {
14 | /* Required for cookies, authorization headers with HTTPS */
15 | 'Access-Control-Allow-Credentials': true,
16 | /* Required for CORS support to work */
17 | 'Access-Control-Allow-Origin': '*',
18 | },
19 | statusCode: 200,
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/services/api-service/src/publicEndpointHandler.test.ts:
--------------------------------------------------------------------------------
1 | import { publicEndpoint } from './publicEndpointHandler';
2 |
3 | describe('private endpoint handler', () => {
4 | beforeEach(() => {
5 | jest.clearAllMocks();
6 | });
7 |
8 | test('should call callback on event', () => {
9 | const event = {};
10 | const context: any = null;
11 | const callback = jest.fn();
12 |
13 | publicEndpoint(event, context, callback);
14 |
15 | expect(callback).toHaveBeenCalledTimes(1);
16 | expect(callback).toHaveBeenCalledWith(null, {
17 | body: JSON.stringify({
18 | message: 'Hi ⊂◉‿◉つ from Public API',
19 | }),
20 | headers: {
21 | /* Required for cookies, authorization headers with HTTPS */
22 | 'Access-Control-Allow-Credentials': true,
23 | /* Required for CORS support to work */
24 | 'Access-Control-Allow-Origin': '*',
25 | },
26 | statusCode: 200,
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/services/api-service/src/publicEndpointHandler.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
2 |
3 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml
4 | export const publicEndpoint: Handler = (
5 | event: APIGatewayEvent,
6 | context: Context,
7 | callback: Callback,
8 | ) => {
9 | callback(null, {
10 | body: JSON.stringify({
11 | message: 'Hi ⊂◉‿◉つ from Public API',
12 | }),
13 | headers: {
14 | /* Required for cookies, authorization headers with HTTPS */
15 | 'Access-Control-Allow-Credentials': true,
16 | /* Required for CORS support to work */
17 | 'Access-Control-Allow-Origin': '*',
18 | },
19 | statusCode: 200,
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/services/api-service/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const slsw = require('serverless-webpack');
3 | const nodeExternals = require('webpack-node-externals');
4 |
5 | module.exports = {
6 | entry: slsw.lib.entries,
7 | target: 'node',
8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
9 | optimization: {
10 | // We no not want to minimize our code.
11 | minimize: false,
12 | },
13 | performance: {
14 | // Turn off size warnings for entry points
15 | hints: false,
16 | },
17 | devtool: 'nosources-source-map',
18 | externals: [nodeExternals()],
19 | resolve: {
20 | extensions: ['.js', '.json', '.ts', '.tsx'],
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.ts(x?)$/,
26 | use: [
27 | {
28 | loader: 'ts-loader',
29 | },
30 | ],
31 | },
32 | {
33 | test: /\.mjs$/,
34 | type: 'javascript/auto',
35 | },
36 | ],
37 | },
38 | output: {
39 | libraryTarget: 'commonjs2',
40 | path: path.join(__dirname, '.webpack'),
41 | filename: '[name].js',
42 | sourceMapFilename: '[file].map',
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/services/common/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Webpack directories
6 | .webpack
7 |
8 | config.*.json
9 | !config.example.json
10 |
11 | public_key.*.pem
12 | !public_key.example.pem
13 |
14 | coverage
15 |
16 | junit.xml
17 |
18 | out.txt
--------------------------------------------------------------------------------
/services/common/README.md:
--------------------------------------------------------------------------------
1 | # Shared Services Code
2 |
3 | ## Setup
4 |
5 | ```bash
6 | yarn install
7 | ```
8 |
9 | ## Create Env Variables From pem files
10 |
11 | ```bash
12 | base64 public_key.example.pem
13 | ```
14 |
--------------------------------------------------------------------------------
/services/common/environment/config.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "region": "REGION_VALUE",
3 | "AUTH0_CLIENT_ID": "AUTH0_CLIENT_ID_VALUE"
4 | }
5 |
--------------------------------------------------------------------------------
/services/common/environment/public_key.example.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | PUBLIC KEY - can be found in https://manage.auth0.com -> applications -> advanced settings -> Certificates
3 | Replace this file with public_key
4 | -----END CERTIFICATE-----
5 |
--------------------------------------------------------------------------------
/services/common/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8 | reporters: ['default', 'jest-junit'],
9 | };
10 |
--------------------------------------------------------------------------------
/services/common/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "common",
3 | "version": "0.0.1",
4 | "description": "Shared Services Code",
5 | "scripts": {
6 | "lint": "tslint 'src/**/*.ts'",
7 | "test": "jest",
8 | "test:watch": "jest --watch",
9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci",
10 | "coverage": "jest --coverage",
11 | "prettier": "prettier --write src/**/*.ts",
12 | "prettier:ci": "prettier --list-different src/**/*.ts"
13 | },
14 | "dependencies": {
15 | "jsonwebtoken": "^8.5.1"
16 | },
17 | "devDependencies": {
18 | "@types/aws-lambda": "^8.10.62",
19 | "@types/jest": "^27.0.0",
20 | "@types/jsonwebtoken": "^8.5.0",
21 | "@types/node": "^16.0.0",
22 | "jest": "27.5.1",
23 | "ts-jest": "27.1.5",
24 | "ts-loader": "^9.0.0",
25 | "tslint": "^6.0.0",
26 | "tslint-config-prettier": "^1.18.0",
27 | "typescript": "^4.0.0"
28 | },
29 | "author": "Erez Rokah",
30 | "license": "MIT"
31 | }
32 |
--------------------------------------------------------------------------------
/services/common/src/auth/authenticator.ts:
--------------------------------------------------------------------------------
1 | import jwt = require('jsonwebtoken');
2 |
3 | // Set in `environment` of serverless.yml
4 | const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID;
5 | // istanbul ignore next
6 | const AUTH0_CLIENT_PUBLIC_KEY = process.env.AUTH0_CLIENT_PUBLIC_KEY || '';
7 |
8 | // Policy helper function
9 | export const generatePolicy = (
10 | principalId: string,
11 | effect: string,
12 | resource: string,
13 | ) => {
14 | if (effect && resource) {
15 | const policyDocument = {
16 | Statement: [
17 | {
18 | Action: 'execute-api:Invoke',
19 | Effect: effect,
20 | Resource: resource,
21 | },
22 | ],
23 | Version: '2012-10-17',
24 | };
25 | return { principalId, policyDocument };
26 | } else {
27 | return { principalId };
28 | }
29 | };
30 |
31 | export const decodeToken = async (
32 | authorizationToken: string | undefined,
33 | methodArn: string,
34 | ) => {
35 | const error = 'Unauthorized';
36 | if (!authorizationToken) {
37 | console.log('missing authorizationToken');
38 | return { response: null, error };
39 | }
40 |
41 | const tokenParts = authorizationToken.split(' ');
42 | const tokenValue = tokenParts[1];
43 |
44 | if (!(tokenParts[0].toLowerCase() === 'bearer' && tokenValue)) {
45 | console.log('invalid authorizationToken value');
46 | return { response: null, error };
47 | }
48 |
49 | const options = {
50 | audience: AUTH0_CLIENT_ID,
51 | };
52 |
53 | const promise = new Promise((resolve, reject) => {
54 | jwt.verify(tokenValue, AUTH0_CLIENT_PUBLIC_KEY, options, (err, decoded) => {
55 | if (err) {
56 | reject(err);
57 | } else {
58 | resolve(decoded);
59 | }
60 | });
61 | });
62 |
63 | try {
64 | const decoded = (await promise) as { sub: string };
65 | console.log('valid from customAuthorizer', decoded);
66 | const response = exports.generatePolicy(decoded.sub, 'Allow', methodArn);
67 | return { response, error: null };
68 | } catch (err) {
69 | console.log('verifyError', err);
70 | return { response: null, error };
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/services/db-service/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 |
8 | # Webpack directories
9 | .webpack
10 |
11 | config.*.json
12 | !config.example.json
13 |
14 | coverage
15 |
16 | junit.xml
17 |
18 | out.txt
--------------------------------------------------------------------------------
/services/db-service/README.md:
--------------------------------------------------------------------------------
1 | # Db Service
2 |
3 | ## Setup
4 |
5 | ```bash
6 | yarn install
7 | ```
8 |
--------------------------------------------------------------------------------
/services/db-service/e2e/db.chai.test.ts:
--------------------------------------------------------------------------------
1 | import awsTesting from 'aws-testing-library/lib/chai';
2 | import { clearAllItems } from 'aws-testing-library/lib/utils/dynamoDb';
3 | import { invoke } from 'aws-testing-library/lib/utils/lambda';
4 | import chai = require('chai');
5 |
6 | chai.use(awsTesting);
7 |
8 | const { expect } = chai;
9 |
10 | describe('db service e2e tests', () => {
11 | const region = 'us-east-1';
12 | const table = 'db-service-dev';
13 | beforeEach(async () => {
14 | await clearAllItems(region, table);
15 | });
16 |
17 | afterEach(async () => {
18 | await clearAllItems(region, table);
19 | });
20 |
21 | test('should create db entry on lambda invoke', async () => {
22 | const result = await invoke(region, 'db-service-dev-create', {
23 | body: JSON.stringify({ text: 'from e2e test' }),
24 | });
25 | const lambdaItem = JSON.parse(result.body);
26 |
27 | await expect({ region, table, timeout: 0 }).to.have.item(
28 | { id: lambdaItem.id },
29 | lambdaItem,
30 | );
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/services/db-service/e2e/db.test.ts:
--------------------------------------------------------------------------------
1 | import { clearAllItems } from 'aws-testing-library/lib/utils/dynamoDb';
2 | import { invoke } from 'aws-testing-library/lib/utils/lambda';
3 |
4 | describe('db service e2e tests', () => {
5 | const region = 'us-east-1';
6 | const table = 'db-service-dev';
7 | beforeEach(async () => {
8 | await clearAllItems(region, table);
9 | });
10 |
11 | afterEach(async () => {
12 | await clearAllItems(region, table);
13 | });
14 |
15 | test('should create db entry on lambda invoke', async () => {
16 | const result = await invoke(region, 'db-service-dev-create', {
17 | body: JSON.stringify({ text: 'from e2e test' }),
18 | });
19 | const lambdaItem = JSON.parse(result.body);
20 |
21 | expect.assertions(1);
22 | await expect({ region, table, timeout: 0 }).toHaveItem(
23 | { id: lambdaItem.id },
24 | lambdaItem,
25 | );
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/services/db-service/e2e/setup.ts:
--------------------------------------------------------------------------------
1 | import { deploy } from 'aws-testing-library/lib/utils/serverless';
2 |
3 | const deployDev = async () => {
4 | await deploy('dev');
5 | };
6 |
7 | export default deployDev;
8 |
--------------------------------------------------------------------------------
/services/db-service/e2e/setupFrameworks.ts:
--------------------------------------------------------------------------------
1 | import 'aws-testing-library/lib/jest';
2 |
--------------------------------------------------------------------------------
/services/db-service/e2e/setup_hook.js:
--------------------------------------------------------------------------------
1 | require('ts-node/register');
2 | module.exports = require('./setup').default;
3 |
--------------------------------------------------------------------------------
/services/db-service/jest.config.e2e.js:
--------------------------------------------------------------------------------
1 | const config = require('./jest.config');
2 |
3 | module.exports = {
4 | ...config,
5 | roots: ['/e2e'],
6 | globalSetup: '/e2e/setup_hook.js',
7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts',
8 | };
9 |
--------------------------------------------------------------------------------
/services/db-service/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8 | reporters: ['default', 'jest-junit'],
9 | testEnvironment: 'node',
10 | };
11 |
--------------------------------------------------------------------------------
/services/db-service/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "db-service",
3 | "version": "0.0.1",
4 | "description": "File Service",
5 | "scripts": {
6 | "lint": "tslint 'src/**/*.ts'",
7 | "test": "jest",
8 | "test:watch": "jest --watch",
9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci",
10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci",
11 | "coverage": "jest --coverage",
12 | "deploy": "serverless deploy",
13 | "remove": "serverless remove",
14 | "prettier": "prettier --write src/**/*.ts",
15 | "prettier:ci": "prettier --list-different src/**/*.ts"
16 | },
17 | "dependencies": {
18 | "aws-sdk": "^2.449.0",
19 | "uuid": "^8.0.0"
20 | },
21 | "devDependencies": {
22 | "@anttiviljami/serverless-stack-output": "^0.3.1",
23 | "@types/aws-lambda": "^8.10.62",
24 | "@types/chai": "^4.2.12",
25 | "@types/jest": "^27.0.0",
26 | "@types/node": "^16.0.0",
27 | "@types/uuid": "^8.0.0",
28 | "aws-testing-library": "^2.0.0",
29 | "chai": "^4.2.0",
30 | "dotenv": "^16.0.0",
31 | "jest": "27.5.1",
32 | "mockdate": "^3.0.0",
33 | "serverless-plugin-tracing": "^2.0.0",
34 | "serverless-webpack": "^5.2.0",
35 | "ts-jest": "27.1.5",
36 | "ts-loader": "^9.0.0",
37 | "tslint": "^6.0.0",
38 | "tslint-config-prettier": "^1.18.0",
39 | "typescript": "^4.0.0",
40 | "webpack": "^5.0.0",
41 | "webpack-node-externals": "^3.0.0"
42 | },
43 | "author": "Erez Rokah",
44 | "license": "MIT"
45 | }
46 |
--------------------------------------------------------------------------------
/services/db-service/serverless.yml:
--------------------------------------------------------------------------------
1 | service: db-service
2 |
3 | plugins:
4 | - serverless-webpack
5 | - '@anttiviljami/serverless-stack-output'
6 | - serverless-plugin-tracing
7 |
8 | custom:
9 | defaultStage: dev
10 | currentStage: ${opt:stage, self:custom.defaultStage}
11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region}
12 |
13 | output:
14 | handler: ../../scripts/stackOutput.handler
15 |
16 | webpack:
17 | includeModules: true
18 | packager: yarn
19 |
20 | provider:
21 | name: aws
22 | runtime: nodejs8.10
23 | stage: ${self:custom.currentStage}
24 | region: ${self:custom.currentRegion}
25 |
26 | memorySize: 256
27 | logRetentionInDays: 7
28 |
29 | tracing: true # enable tracing
30 |
31 | environment:
32 | DYNAMODB_TABLE: ${self:service}-${opt:stage, self:provider.stage}
33 |
34 | iamRoleStatements:
35 | - Effect: 'Allow' # xray permissions (required)
36 | Action:
37 | - 'xray:PutTraceSegments'
38 | - 'xray:PutTelemetryRecords'
39 | Resource:
40 | - '*'
41 |
42 | - Effect: Allow
43 | Action:
44 | - dynamodb:Query
45 | - dynamodb:Scan
46 | - dynamodb:GetItem
47 | - dynamodb:PutItem
48 | - dynamodb:UpdateItem
49 | - dynamodb:DeleteItem
50 | Resource: 'arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.DYNAMODB_TABLE}'
51 |
52 | functions:
53 | create:
54 | handler: src/create.create
55 | events:
56 | - http:
57 | path: todos
58 | method: post
59 | cors: true
60 |
61 | resources:
62 | Resources:
63 | TodosDynamoDbTable:
64 | Type: 'AWS::DynamoDB::Table'
65 | DeletionPolicy: Retain
66 | Properties:
67 | AttributeDefinitions:
68 | - AttributeName: id
69 | AttributeType: S
70 | KeySchema:
71 | - AttributeName: id
72 | KeyType: HASH
73 | ProvisionedThroughput:
74 | ReadCapacityUnits: 1
75 | WriteCapacityUnits: 1
76 | TableName: ${self:provider.environment.DYNAMODB_TABLE}
77 |
--------------------------------------------------------------------------------
/services/db-service/src/create.test.ts:
--------------------------------------------------------------------------------
1 | import { create } from './create';
2 |
3 | jest.mock('./db');
4 |
5 | describe('create handler', () => {
6 | const { create: dbCreate } = require('./db');
7 | const response = {};
8 | const error = new Error('some error');
9 |
10 | const payload = {
11 | text: 'text',
12 | };
13 | const event = { body: JSON.stringify(payload) };
14 | const context: any = null;
15 |
16 | beforeEach(() => {
17 | jest.clearAllMocks();
18 | });
19 |
20 | test('should call callback on response', async () => {
21 | dbCreate.mockReturnValue(Promise.resolve({ response, error: null }));
22 |
23 | const callback = jest.fn();
24 |
25 | await create(event, context, callback);
26 |
27 | expect(dbCreate).toHaveBeenCalledTimes(1);
28 | expect(dbCreate).toHaveBeenCalledWith(JSON.stringify(payload));
29 | expect(callback).toHaveBeenCalledTimes(1);
30 | expect(callback).toHaveBeenCalledWith(null, response);
31 | });
32 |
33 | test('should call callback on error', async () => {
34 | dbCreate.mockReturnValue(Promise.resolve({ response, error }));
35 |
36 | const callback = jest.fn();
37 |
38 | await create(event, context, callback);
39 |
40 | expect(dbCreate).toHaveBeenCalledTimes(1);
41 | expect(dbCreate).toHaveBeenCalledWith(JSON.stringify(payload));
42 | expect(callback).toHaveBeenCalledTimes(1);
43 | expect(callback).toHaveBeenCalledWith(error);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/services/db-service/src/create.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
2 | import { create as dbCreate } from './db';
3 |
4 | export const create: Handler = (
5 | event: APIGatewayEvent,
6 | context: Context,
7 | callback?: Callback,
8 | ) => {
9 | return dbCreate(event.body).then(({ response, error }) => {
10 | if (error) {
11 | callback && callback(error);
12 | } else {
13 | callback && callback(null, response);
14 | }
15 | });
16 | };
17 |
--------------------------------------------------------------------------------
/services/db-service/src/db.test.ts:
--------------------------------------------------------------------------------
1 | jest.mock('aws-sdk', () => {
2 | const promise = jest.fn();
3 | const put = jest.fn(() => ({ promise }));
4 | const DocumentClient = jest.fn(() => ({ put }));
5 | const DynamoDB = { DocumentClient };
6 | return { DynamoDB };
7 | });
8 | jest.spyOn(console, 'log');
9 | jest.mock('uuid/v1');
10 |
11 | process.env.DYNAMODB_TABLE = 'TableName';
12 |
13 | describe('db', () => {
14 | const headers = {
15 | 'Access-Control-Allow-Credentials': true,
16 | 'Access-Control-Allow-Origin': '*',
17 | };
18 |
19 | const MockDate = require('mockdate');
20 |
21 | beforeEach(() => {
22 | jest.clearAllMocks();
23 |
24 | const date = '1/1/2000';
25 | MockDate.set(date);
26 | });
27 |
28 | afterEach(() => {
29 | MockDate.reset();
30 | });
31 |
32 | const badDataResponse = (input: any) => ({
33 | body: JSON.stringify({
34 | input,
35 | message: 'Bad input data or missing text',
36 | }),
37 | headers,
38 | statusCode: 422,
39 | });
40 |
41 | const AWS = require('aws-sdk');
42 | const dynamoDB = AWS.DynamoDB.DocumentClient;
43 |
44 | test('should return response on empty body', async () => {
45 | const { create } = require('./db');
46 |
47 | const body = '';
48 |
49 | const { response, error } = await create(body);
50 |
51 | expect(dynamoDB).toHaveBeenCalledTimes(1);
52 | expect(response).toEqual(badDataResponse(body));
53 | expect(error).toBeNull();
54 | });
55 |
56 | test('should return response on malformed body', async () => {
57 | const { create } = require('./db');
58 |
59 | const body = 'invalid-json';
60 |
61 | const { response, error } = await create(body);
62 |
63 | expect(dynamoDB).toHaveBeenCalledTimes(1);
64 | expect(response).toEqual(badDataResponse(body));
65 | expect(error).toBeNull();
66 | expect(console.log).toHaveBeenCalledTimes(1);
67 | expect(console.log).toHaveBeenCalledWith(
68 | 'Invalid body',
69 | expect.any(SyntaxError),
70 | );
71 | });
72 |
73 | test('should return response on empty text', async () => {
74 | const { create } = require('./db');
75 |
76 | const body = JSON.stringify({ text: '' });
77 |
78 | const { response, error } = await create(body);
79 |
80 | expect(dynamoDB).toHaveBeenCalledTimes(1);
81 | expect(response).toEqual(badDataResponse(body));
82 | expect(error).toBeNull();
83 | });
84 |
85 | test('should return response on valid text', async () => {
86 | const { create } = require('./db');
87 |
88 | const uuid = require('uuid/v1');
89 | const id = 'someUuid';
90 | uuid.mockReturnValue(id);
91 |
92 | const text = 'textToWrite';
93 | const body = JSON.stringify({ text });
94 |
95 | const { response, error } = await create(body);
96 |
97 | expect(uuid).toHaveBeenCalledTimes(1);
98 |
99 | const params = {
100 | Item: {
101 | checked: false,
102 | createdAt: new Date().getTime(),
103 | id,
104 | text,
105 | updatedAt: new Date().getTime(),
106 | },
107 | TableName: process.env.DYNAMODB_TABLE,
108 | };
109 |
110 | expect(dynamoDB().put).toHaveBeenCalledTimes(1);
111 | expect(dynamoDB().put).toHaveBeenCalledWith(params);
112 | expect(dynamoDB().put().promise).toHaveBeenCalledTimes(1);
113 |
114 | expect(response).toEqual({
115 | body: JSON.stringify(params.Item),
116 | headers,
117 | statusCode: 200,
118 | });
119 | expect(error).toBeNull();
120 | });
121 |
122 | test('should return response on promise rejects', async () => {
123 | const { create } = require('./db');
124 |
125 | const text = 'textToWrite';
126 | const body = JSON.stringify({ text });
127 |
128 | const expectedError = new Error(`Failed to save to db`);
129 | dynamoDB()
130 | .put()
131 | .promise.mockReturnValue(Promise.reject(expectedError));
132 |
133 | const { response, error } = await create(body);
134 |
135 | expect(response).toEqual({
136 | body: JSON.stringify({
137 | input: body,
138 | message: "Couldn't create the todo item.",
139 | }),
140 | headers,
141 | statusCode: 500,
142 | });
143 |
144 | expect(error).toEqual(expectedError);
145 | expect(console.log).toHaveBeenCalledTimes(1);
146 | expect(console.log).toHaveBeenCalledWith(error);
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/services/db-service/src/db.ts:
--------------------------------------------------------------------------------
1 | import { DynamoDB } from 'aws-sdk';
2 | import { v1 as uuid } from 'uuid';
3 |
4 | const TableName = process.env.DYNAMODB_TABLE || '';
5 |
6 | const headers = {
7 | /* Required for cookies, authorization headers with HTTPS */
8 | 'Access-Control-Allow-Credentials': true,
9 | /* Required for CORS support to work */
10 | 'Access-Control-Allow-Origin': '*',
11 | };
12 |
13 | export const create = async (body: string | null) => {
14 | const dynamoDb = new DynamoDB.DocumentClient();
15 |
16 | let text = '';
17 | if (body) {
18 | try {
19 | const parsed = JSON.parse(body);
20 | text = parsed.text || '';
21 | } catch (e) {
22 | console.log('Invalid body', e);
23 | }
24 | }
25 |
26 | if (text !== '' && typeof text === 'string') {
27 | try {
28 | const createdAt = new Date().getTime();
29 | const params = {
30 | Item: {
31 | checked: false,
32 | createdAt,
33 | id: uuid(),
34 | text,
35 | updatedAt: createdAt,
36 | },
37 | TableName,
38 | };
39 |
40 | // write the todo to the database
41 | await dynamoDb.put(params).promise();
42 |
43 | // create a response
44 | const response = {
45 | body: JSON.stringify(params.Item),
46 | headers,
47 | statusCode: 200,
48 | };
49 | return { response, error: null };
50 | } catch (error) {
51 | console.log(error);
52 | const response = {
53 | body: JSON.stringify({
54 | input: body,
55 | message: "Couldn't create the todo item.",
56 | }),
57 | headers,
58 | statusCode: 500,
59 | };
60 | return { response, error };
61 | }
62 | } else {
63 | const response = {
64 | body: JSON.stringify({
65 | input: body,
66 | message: 'Bad input data or missing text',
67 | }),
68 | headers,
69 | statusCode: 422,
70 | };
71 | return { response, error: null };
72 | }
73 | };
74 |
--------------------------------------------------------------------------------
/services/db-service/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const slsw = require('serverless-webpack');
3 | const nodeExternals = require('webpack-node-externals');
4 |
5 | module.exports = {
6 | entry: slsw.lib.entries,
7 | target: 'node',
8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
9 | optimization: {
10 | // We no not want to minimize our code.
11 | minimize: false,
12 | },
13 | performance: {
14 | // Turn off size warnings for entry points
15 | hints: false,
16 | },
17 | devtool: 'nosources-source-map',
18 | externals: [nodeExternals()],
19 | resolve: {
20 | extensions: ['.js', '.json', '.ts', '.tsx'],
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.ts(x?)$/,
26 | use: [
27 | {
28 | loader: 'ts-loader',
29 | },
30 | ],
31 | },
32 | {
33 | test: /\.mjs$/,
34 | type: 'javascript/auto',
35 | },
36 | ],
37 | },
38 | output: {
39 | libraryTarget: 'commonjs2',
40 | path: path.join(__dirname, '.webpack'),
41 | filename: '[name].js',
42 | sourceMapFilename: '[file].map',
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/services/email-service/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 |
8 | # Webpack directories
9 | .webpack
10 |
11 | config.*.json
12 | !config.example.json
13 |
14 | coverage
15 |
16 | junit.xml
--------------------------------------------------------------------------------
/services/email-service/README.md:
--------------------------------------------------------------------------------
1 | # Email Service
2 |
3 | ## Setup
4 |
5 | ```bash
6 | yarn install
7 | ```
8 |
9 | ## Invoke Locally
10 |
11 | ```bash
12 | sls invoke local --stage dev --function send -p send-email-data.json
13 | ```
14 |
--------------------------------------------------------------------------------
/services/email-service/config.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "SENDGRID_API_KEY": "SENDGRID_API_KEY_VALUE"
3 | }
4 |
--------------------------------------------------------------------------------
/services/email-service/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8 | reporters: ['default', 'jest-junit'],
9 | testEnvironment: 'node',
10 | };
11 |
--------------------------------------------------------------------------------
/services/email-service/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "email-service",
3 | "version": "0.0.1",
4 | "description": "Serverless Email Service",
5 | "main": "handler.ts",
6 | "scripts": {
7 | "lint": "tslint 'src/**/*.ts'",
8 | "test": "jest",
9 | "test:watch": "jest --watch",
10 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-service-test-results.xml jest --runInBand --ci",
11 | "coverage": "jest --coverage",
12 | "deploy": "serverless deploy",
13 | "remove": "serverless remove",
14 | "prettier": "prettier --write src/**/*.ts",
15 | "prettier:ci": "prettier --list-different src/**/*.ts"
16 | },
17 | "dependencies": {
18 | "@sendgrid/mail": "^7.0.0",
19 | "aws-xray-sdk": "^3.0.0",
20 | "source-map-support": "^0.5.12"
21 | },
22 | "devDependencies": {
23 | "@types/aws-lambda": "^8.10.62",
24 | "@types/jest": "^27.0.0",
25 | "@types/node": "^16.0.0",
26 | "dotenv": "^16.0.0",
27 | "fs-extra": "^10.0.0",
28 | "jest": "27.5.1",
29 | "serverless-plugin-tracing": "^2.0.0",
30 | "@anttiviljami/serverless-stack-output": "^0.3.1",
31 | "serverless-webpack": "^5.2.0",
32 | "ts-jest": "27.1.5",
33 | "ts-loader": "^9.0.0",
34 | "tslint": "^6.0.0",
35 | "tslint-config-prettier": "^1.18.0",
36 | "typescript": "^4.0.0",
37 | "webpack": "^5.0.0",
38 | "webpack-node-externals": "^3.0.0"
39 | },
40 | "author": "Erez Rokah",
41 | "license": "MIT"
42 | }
43 |
--------------------------------------------------------------------------------
/services/email-service/send-email-data.json:
--------------------------------------------------------------------------------
1 | {
2 | "resource": "/email",
3 | "path": "/email",
4 | "httpMethod": "POST",
5 | "headers": {},
6 | "queryStringParameters": null,
7 | "pathParameters": null,
8 | "stageVariables": null,
9 | "requestContext": {
10 | "path": "/dev/email",
11 | "stage": "dev",
12 | "requestId": "123",
13 | "resourcePath": "/email",
14 | "httpMethod": "POST"
15 | },
16 | "body": "{\"to_address\":\"erezrokah@gmail.com\"}",
17 | "isBase64Encoded": false
18 | }
19 |
--------------------------------------------------------------------------------
/services/email-service/serverless.yml:
--------------------------------------------------------------------------------
1 | service: email-service
2 |
3 | plugins:
4 | - serverless-webpack
5 | - '@anttiviljami/serverless-stack-output'
6 | - serverless-plugin-tracing
7 |
8 | custom:
9 | defaultStage: dev
10 | currentStage: ${opt:stage, self:custom.defaultStage}
11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region}
12 |
13 | output:
14 | handler: ../../scripts/stackOutput.handler
15 |
16 | webpack:
17 | includeModules: true
18 | packager: yarn
19 |
20 | provider:
21 | name: aws
22 | runtime: nodejs8.10
23 | stage: ${self:custom.currentStage}
24 | region: ${self:custom.currentRegion}
25 |
26 | memorySize: 256
27 | logRetentionInDays: 7
28 |
29 | tracing: true # enable tracing
30 | iamRoleStatements:
31 | - Effect: 'Allow' # xray permissions (required)
32 | Action:
33 | - 'xray:PutTraceSegments'
34 | - 'xray:PutTelemetryRecords'
35 | Resource:
36 | - '*'
37 |
38 | environment:
39 | AUTH0_CLIENT_ID: ${file(../common/environment/config.${self:custom.currentStage}.json):AUTH0_CLIENT_ID}
40 | AUTH0_CLIENT_PUBLIC_KEY: ${file(../common/environment/public_key.${self:custom.currentStage}.pem)}
41 |
42 | SENDGRID_API_KEY: ${file(./config.${self:custom.currentStage}.json):SENDGRID_API_KEY}
43 |
44 | functions:
45 | auth:
46 | handler: src/authHandler.auth
47 | cors: true
48 |
49 | send:
50 | handler: src/handler.sendEmail
51 | events:
52 | - http:
53 | path: email
54 | method: post
55 | authorizer: auth
56 | cors: true
57 |
58 | resources:
59 | Resources:
60 | # This response is needed for custom authorizer failures cors support ¯\_(ツ)_/¯
61 | GatewayResponse:
62 | Type: 'AWS::ApiGateway::GatewayResponse'
63 | Properties:
64 | ResponseParameters:
65 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
66 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
67 | ResponseType: EXPIRED_TOKEN
68 | RestApiId:
69 | Ref: 'ApiGatewayRestApi'
70 | StatusCode: '401'
71 | AuthFailureGatewayResponse:
72 | Type: 'AWS::ApiGateway::GatewayResponse'
73 | Properties:
74 | ResponseParameters:
75 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
76 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
77 | ResponseType: UNAUTHORIZED
78 | RestApiId:
79 | Ref: 'ApiGatewayRestApi'
80 | StatusCode: '401'
81 |
82 | package:
83 | individually: true
84 | exclude:
85 | - coverage/**
86 | - .circleci/**
87 |
--------------------------------------------------------------------------------
/services/email-service/src/authHandler.test.ts:
--------------------------------------------------------------------------------
1 | import { auth } from './authHandler';
2 |
3 | jest.mock('../../common/src/auth/authenticator');
4 |
5 | describe('auth handler', () => {
6 | const { decodeToken } = require('../../common/src/auth/authenticator');
7 | const response = {};
8 | const error = new Error('some error');
9 |
10 | beforeEach(() => {
11 | jest.clearAllMocks();
12 | });
13 |
14 | test('should call callback on response', async () => {
15 | decodeToken.mockReturnValue(Promise.resolve({ response, error: null }));
16 |
17 | const event = {
18 | authorizationToken: 'authorizationToken',
19 | methodArn: 'methodArn',
20 | };
21 | const context: any = null;
22 | const callback = jest.fn();
23 |
24 | await auth(event, context, callback);
25 |
26 | expect(decodeToken).toHaveBeenCalledTimes(1);
27 | expect(decodeToken).toHaveBeenCalledWith(
28 | event.authorizationToken,
29 | event.methodArn,
30 | );
31 | expect(callback).toHaveBeenCalledTimes(1);
32 | expect(callback).toHaveBeenCalledWith(null, response);
33 | });
34 |
35 | test('should call callback on error', async () => {
36 | decodeToken.mockReturnValue(Promise.resolve({ response, error }));
37 |
38 | const event = {
39 | authorizationToken: 'authorizationToken',
40 | methodArn: 'methodArn',
41 | };
42 | const context: any = null;
43 | const callback = jest.fn();
44 |
45 | await auth(event, context, callback);
46 |
47 | expect(decodeToken).toHaveBeenCalledTimes(1);
48 | expect(decodeToken).toHaveBeenCalledWith(
49 | event.authorizationToken,
50 | event.methodArn,
51 | );
52 | expect(callback).toHaveBeenCalledTimes(1);
53 | expect(callback).toHaveBeenCalledWith(error);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/services/email-service/src/authHandler.ts:
--------------------------------------------------------------------------------
1 | import { Callback, Context, CustomAuthorizerEvent, Handler } from 'aws-lambda';
2 | import { decodeToken } from '../../common/src/auth/authenticator';
3 |
4 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml
5 | export const auth: Handler = (
6 | event: CustomAuthorizerEvent,
7 | context: Context,
8 | callback: Callback,
9 | ) => {
10 | console.log(event);
11 | return decodeToken(event.authorizationToken, event.methodArn).then(
12 | ({ response, error }) => {
13 | if (error) {
14 | callback && callback(error);
15 | } else {
16 | callback && callback(null, response);
17 | }
18 | },
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/services/email-service/src/handler.test.ts:
--------------------------------------------------------------------------------
1 | import { sendEmail } from './handler';
2 |
3 | jest.mock('./mailSender');
4 |
5 | describe('handler', () => {
6 | const { sendMail } = require('./mailSender');
7 | const response = {};
8 | const error = new Error('some error');
9 |
10 | const payload = { to_address: 'to@email.com' };
11 | const event = { body: JSON.stringify(payload) };
12 | const context: any = null;
13 |
14 | beforeEach(() => {
15 | jest.clearAllMocks();
16 | });
17 |
18 | test('should call callback on response', async () => {
19 | sendMail.mockReturnValue(Promise.resolve({ response, error: null }));
20 |
21 | const callback = jest.fn();
22 |
23 | await sendEmail(event, context, callback);
24 |
25 | expect(sendMail).toHaveBeenCalledTimes(1);
26 | expect(sendMail).toHaveBeenCalledWith(JSON.stringify(payload));
27 | expect(callback).toHaveBeenCalledTimes(1);
28 | expect(callback).toHaveBeenCalledWith(null, response);
29 | });
30 |
31 | test('should call callback on error', async () => {
32 | sendMail.mockReturnValue(Promise.resolve({ response, error }));
33 |
34 | const callback = jest.fn();
35 |
36 | await sendEmail(event, context, callback);
37 |
38 | expect(sendMail).toHaveBeenCalledTimes(1);
39 | expect(sendMail).toHaveBeenCalledWith(JSON.stringify(payload));
40 | expect(callback).toHaveBeenCalledTimes(1);
41 | expect(callback).toHaveBeenCalledWith(error);
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/services/email-service/src/handler.ts:
--------------------------------------------------------------------------------
1 | // tslint:disable-next-line:no-var-requires
2 | const AWSXRay = require('aws-xray-sdk');
3 | // tslint:disable-next-line:no-var-requires
4 | AWSXRay.captureHTTPsGlobal(require('http'));
5 |
6 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
7 | import { sendMail } from './mailSender';
8 |
9 | export const sendEmail: Handler = (
10 | event: APIGatewayEvent,
11 | context: Context,
12 | callback?: Callback,
13 | ) => {
14 | return sendMail(event.body).then(({ response, error }) => {
15 | if (error) {
16 | callback && callback(error);
17 | } else {
18 | callback && callback(null, response);
19 | }
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/services/email-service/src/mailSender.ts:
--------------------------------------------------------------------------------
1 | import sgMail = require('@sendgrid/mail');
2 |
3 | sgMail.setApiKey(process.env.SENDGRID_API_KEY || '');
4 |
5 | export const from = ``;
6 | export const subject = 'Serverless Email Demo';
7 | export const text = 'Sample email sent from Serverless Email Demo.';
8 | export const html = `
9 |
10 | Serverless Email Demo
11 |
12 |
13 |
Serverless Email Demo
14 | Sample email sent from Serverless Email Demo.
15 |
16 |
17 |
18 | `;
19 |
20 | export const sendMail = async (body: string | null) => {
21 | let to = '';
22 | if (body) {
23 | try {
24 | to = JSON.parse(body).to_address || '';
25 | } catch (e) {
26 | console.log('Invalid body', e);
27 | }
28 | }
29 |
30 | if (to !== '') {
31 | const msg = {
32 | from,
33 | html,
34 | subject,
35 | text,
36 | to,
37 | };
38 |
39 | try {
40 | const [, input] = await sgMail.send(msg);
41 | const response = {
42 | body: JSON.stringify({
43 | input,
44 | message: 'Request to send email is successful.',
45 | }),
46 | headers: {
47 | /* Required for cookies, authorization headers with HTTPS */
48 | 'Access-Control-Allow-Credentials': true,
49 | /* Required for CORS support to work */
50 | 'Access-Control-Allow-Origin': '*',
51 | },
52 | statusCode: 202,
53 | };
54 | console.log(response);
55 | return { response, error: null };
56 | } catch (error) {
57 | console.log(error);
58 | const response = {
59 | body: JSON.stringify({
60 | input: body,
61 | message: 'Unknown Error',
62 | }),
63 | headers: {
64 | /* Required for cookies, authorization headers with HTTPS */
65 | 'Access-Control-Allow-Credentials': true,
66 | /* Required for CORS support to work */
67 | 'Access-Control-Allow-Origin': '*',
68 | },
69 | statusCode: 500,
70 | };
71 | return { response, error };
72 | }
73 | } else {
74 | const response = {
75 | body: JSON.stringify({
76 | input: body,
77 | message: 'Bad input data or missing email address.',
78 | }),
79 | headers: {
80 | /* Required for cookies, authorization headers with HTTPS */
81 | 'Access-Control-Allow-Credentials': true,
82 | /* Required for CORS support to work */
83 | 'Access-Control-Allow-Origin': '*',
84 | },
85 | statusCode: 422,
86 | };
87 | return { response, error: null };
88 | }
89 | };
90 |
--------------------------------------------------------------------------------
/services/email-service/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const slsw = require('serverless-webpack');
3 | const nodeExternals = require('webpack-node-externals');
4 |
5 | module.exports = {
6 | entry: slsw.lib.entries,
7 | target: 'node',
8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
9 | optimization: {
10 | // We no not want to minimize our code.
11 | minimize: false,
12 | },
13 | performance: {
14 | // Turn off size warnings for entry points
15 | hints: false,
16 | },
17 | devtool: 'nosources-source-map',
18 | externals: [nodeExternals()],
19 | resolve: {
20 | extensions: ['.js', '.json', '.ts', '.tsx'],
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.ts(x?)$/,
26 | use: [
27 | {
28 | loader: 'ts-loader',
29 | },
30 | ],
31 | },
32 | ],
33 | },
34 | output: {
35 | libraryTarget: 'commonjs2',
36 | path: path.join(__dirname, '.webpack'),
37 | filename: '[name].js',
38 | sourceMapFilename: '[file].map',
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/services/file-service/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 |
8 | # Webpack directories
9 | .webpack
10 |
11 | config.*.json
12 | !config.example.json
13 |
14 | coverage
15 |
16 | junit.xml
17 |
18 | out.txt
--------------------------------------------------------------------------------
/services/file-service/README.md:
--------------------------------------------------------------------------------
1 | # File Service
2 |
3 | ## Setup
4 |
5 | ```bash
6 | yarn install
7 | ```
8 |
--------------------------------------------------------------------------------
/services/file-service/e2e/handler.chai.test.ts:
--------------------------------------------------------------------------------
1 | import awsTesting from 'aws-testing-library/lib/chai';
2 | import { invoke } from 'aws-testing-library/lib/utils/lambda';
3 | import { clearAllObjects } from 'aws-testing-library/lib/utils/s3';
4 | import chai = require('chai');
5 | import fetch from 'node-fetch';
6 |
7 | chai.use(awsTesting);
8 |
9 | const { expect } = chai;
10 |
11 | describe('file service e2e tests', () => {
12 | const region = 'us-east-1';
13 | const bucket = 'file-service-s3-bucket-dev';
14 |
15 | beforeEach(async () => {
16 | await clearAllObjects(region, bucket);
17 | });
18 |
19 | afterEach(async () => {
20 | await clearAllObjects(region, bucket);
21 | });
22 |
23 | test('should create object in s3 on lambda invoke', async () => {
24 | const body = {
25 | file_url:
26 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/240px-Google_2015_logo.svg.png',
27 | key: '240px-Google_2015_logo.svg.png',
28 | };
29 |
30 | const result = await invoke(region, 'file-service-dev-save', {
31 | body: JSON.stringify(body),
32 | });
33 |
34 | const parsedResult = JSON.parse(result.body);
35 | expect(parsedResult.message).to.be.equal('File saved');
36 |
37 | const expectedBuffer = await (await fetch(body.file_url)).buffer();
38 |
39 | await expect({ region, bucket, timeout: 0 }).to.have.object(
40 | body.key,
41 | expectedBuffer,
42 | );
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/services/file-service/e2e/handler.test.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from 'aws-testing-library/lib/utils/lambda';
2 | import { clearAllObjects } from 'aws-testing-library/lib/utils/s3';
3 | import fetch from 'node-fetch';
4 |
5 | describe('file service e2e tests', () => {
6 | const region = 'us-east-1';
7 | const bucket = 'file-service-s3-bucket-dev';
8 |
9 | beforeEach(async () => {
10 | await clearAllObjects(region, bucket);
11 | });
12 |
13 | afterEach(async () => {
14 | await clearAllObjects(region, bucket);
15 | });
16 |
17 | test('should create object in s3 on lambda invoke', async () => {
18 | const body = {
19 | file_url:
20 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/240px-Google_2015_logo.svg.png',
21 | key: '240px-Google_2015_logo.svg.png',
22 | };
23 |
24 | const result = await invoke(region, 'file-service-dev-save', {
25 | body: JSON.stringify(body),
26 | });
27 |
28 | const parsedResult = JSON.parse(result.body);
29 | expect(parsedResult.message).toEqual('File saved');
30 |
31 | const expectedBuffer = await (await fetch(body.file_url)).buffer();
32 |
33 | expect.assertions(2);
34 | await expect({ region, bucket, timeout: 0 }).toHaveObject(
35 | body.key,
36 | expectedBuffer,
37 | );
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/services/file-service/e2e/setup.ts:
--------------------------------------------------------------------------------
1 | import { deploy } from 'aws-testing-library/lib/utils/serverless';
2 |
3 | const deployDev = async () => {
4 | await deploy('dev');
5 | };
6 |
7 | export default deployDev;
8 |
--------------------------------------------------------------------------------
/services/file-service/e2e/setupFrameworks.ts:
--------------------------------------------------------------------------------
1 | import 'aws-testing-library/lib/jest';
2 |
--------------------------------------------------------------------------------
/services/file-service/e2e/setup_hook.js:
--------------------------------------------------------------------------------
1 | require('ts-node/register');
2 | module.exports = require('./setup').default;
3 |
--------------------------------------------------------------------------------
/services/file-service/jest.config.e2e.js:
--------------------------------------------------------------------------------
1 | const config = require('./jest.config');
2 |
3 | module.exports = {
4 | ...config,
5 | roots: ['/e2e'],
6 | globalSetup: '/e2e/setup_hook.js',
7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts',
8 | };
9 |
--------------------------------------------------------------------------------
/services/file-service/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8 | reporters: ['default', 'jest-junit'],
9 | testEnvironment: 'node',
10 | };
11 |
--------------------------------------------------------------------------------
/services/file-service/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "file-service",
3 | "version": "0.0.1",
4 | "description": "File Service",
5 | "scripts": {
6 | "lint": "tslint 'src/**/*.ts'",
7 | "test": "jest",
8 | "test:watch": "jest --watch",
9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci",
10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci",
11 | "coverage": "jest --coverage",
12 | "deploy": "serverless deploy",
13 | "remove": "serverless remove",
14 | "prettier": "prettier --write src/**/*.ts",
15 | "prettier:ci": "prettier --list-different src/**/*.ts"
16 | },
17 | "dependencies": {
18 | "aws-sdk": "^2.449.0",
19 | "node-fetch": "^3.0.0"
20 | },
21 | "devDependencies": {
22 | "@anttiviljami/serverless-stack-output": "^0.3.1",
23 | "@types/aws-lambda": "^8.10.62",
24 | "@types/chai": "^4.2.12",
25 | "@types/jest": "^27.0.0",
26 | "@types/node": "^16.0.0",
27 | "@types/node-fetch": "^3.0.0",
28 | "aws-testing-library": "^2.0.0",
29 | "chai": "^4.2.0",
30 | "jest": "27.5.1",
31 | "serverless-plugin-tracing": "^2.0.0",
32 | "serverless-webpack": "^5.2.0",
33 | "ts-jest": "27.1.5",
34 | "ts-loader": "^9.0.0",
35 | "tslint": "^6.0.0",
36 | "tslint-config-prettier": "^1.18.0",
37 | "typescript": "^4.0.0",
38 | "webpack": "^5.0.0",
39 | "webpack-node-externals": "^3.0.0"
40 | },
41 | "author": "Erez Rokah",
42 | "license": "MIT"
43 | }
44 |
--------------------------------------------------------------------------------
/services/file-service/serverless.yml:
--------------------------------------------------------------------------------
1 | service: file-service
2 |
3 | plugins:
4 | - serverless-webpack
5 | - '@anttiviljami/serverless-stack-output'
6 | - serverless-plugin-tracing
7 |
8 | custom:
9 | defaultStage: dev
10 | currentStage: ${opt:stage, self:custom.defaultStage}
11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region}
12 | s3Bucket: file-service-s3-bucket-${self:custom.currentStage}
13 |
14 | output:
15 | handler: ../../scripts/stackOutput.handler
16 |
17 | webpack:
18 | includeModules: true
19 | packager: yarn
20 |
21 | provider:
22 | name: aws
23 | runtime: nodejs8.10
24 | stage: ${self:custom.currentStage}
25 | region: ${self:custom.currentRegion}
26 |
27 | memorySize: 256
28 | logRetentionInDays: 7
29 |
30 | tracing: true # enable tracing
31 | iamRoleStatements:
32 | - Effect: 'Allow' # xray permissions (required)
33 | Action:
34 | - 'xray:PutTraceSegments'
35 | - 'xray:PutTelemetryRecords'
36 | Resource:
37 | - '*'
38 |
39 | - Effect: Allow
40 | Action:
41 | - s3:PutObject
42 | Resource: 'arn:aws:s3:::${self:custom.s3Bucket}/*'
43 |
44 | functions:
45 | save:
46 | handler: src/handler.save
47 | events:
48 | - http:
49 | path: save
50 | method: post
51 | cors: true
52 | environment:
53 | BUCKET: ${self:custom.s3Bucket}
54 |
55 | resources:
56 | Resources:
57 | FileServiceBucket:
58 | Type: AWS::S3::Bucket
59 | Properties:
60 | BucketName: ${self:custom.s3Bucket}
61 | AccessControl: Private
62 |
--------------------------------------------------------------------------------
/services/file-service/src/fileSaver.ts:
--------------------------------------------------------------------------------
1 | import { S3 } from 'aws-sdk';
2 | import fetch from 'node-fetch';
3 |
4 | const Bucket = process.env.BUCKET || '';
5 |
6 | const headers = {
7 | /* Required for cookies, authorization headers with HTTPS */
8 | 'Access-Control-Allow-Credentials': true,
9 | /* Required for CORS support to work */
10 | 'Access-Control-Allow-Origin': '*',
11 | };
12 |
13 | export const saveFile = async (body: string | null) => {
14 | const s3 = new S3();
15 |
16 | let fileUrl = '';
17 | let key = '';
18 | if (body) {
19 | try {
20 | const parsed = JSON.parse(body);
21 | fileUrl = parsed.file_url || '';
22 | key = parsed.key || '';
23 | } catch (e) {
24 | console.log('Invalid body', e);
25 | }
26 | }
27 |
28 | if (fileUrl !== '' && key !== '') {
29 | try {
30 | const fetchResponse = await fetch(fileUrl);
31 | if (fetchResponse.ok) {
32 | const buffer = await fetchResponse.buffer();
33 | await s3
34 | .putObject({
35 | Body: buffer,
36 | Bucket,
37 | Key: key,
38 | })
39 | .promise();
40 | const response = {
41 | body: JSON.stringify({
42 | input: body,
43 | message: 'File saved',
44 | }),
45 | headers,
46 | statusCode: 200,
47 | };
48 | return { response, error: null };
49 | } else {
50 | const response = {
51 | body: JSON.stringify({
52 | input: body,
53 | message: 'Fetch failed',
54 | }),
55 | headers,
56 | statusCode: 500,
57 | };
58 | const error = new Error(
59 | `Failed to fetch ${fetchResponse.url}: ${fetchResponse.status} ${
60 | fetchResponse.statusText
61 | }`,
62 | );
63 | return { response, error };
64 | }
65 | } catch (error) {
66 | console.log(error);
67 | const response = {
68 | body: JSON.stringify({
69 | input: body,
70 | message: 'Unknown Error',
71 | }),
72 | headers,
73 | statusCode: 500,
74 | };
75 | return { response, error };
76 | }
77 | } else {
78 | const response = {
79 | body: JSON.stringify({
80 | input: body,
81 | message: 'Bad input data or missing file url.',
82 | }),
83 | headers,
84 | statusCode: 422,
85 | };
86 | return { response, error: null };
87 | }
88 | };
89 |
--------------------------------------------------------------------------------
/services/file-service/src/handler.test.ts:
--------------------------------------------------------------------------------
1 | import { save } from './handler';
2 |
3 | jest.mock('./fileSaver');
4 |
5 | describe('handler', () => {
6 | const { saveFile } = require('./fileSaver');
7 | const response = {};
8 | const error = new Error('some error');
9 |
10 | const payload = {
11 | file_url:
12 | 'https://assets-cdn.github.com/images/modules/open_graph/github-mark.png',
13 | key: 'github.png',
14 | };
15 | const event = { body: JSON.stringify(payload) };
16 | const context: any = null;
17 |
18 | beforeEach(() => {
19 | jest.clearAllMocks();
20 | });
21 |
22 | test('should call callback on response', async () => {
23 | saveFile.mockReturnValue(Promise.resolve({ response, error: null }));
24 |
25 | const callback = jest.fn();
26 |
27 | await save(event, context, callback);
28 |
29 | expect(saveFile).toHaveBeenCalledTimes(1);
30 | expect(saveFile).toHaveBeenCalledWith(JSON.stringify(payload));
31 | expect(callback).toHaveBeenCalledTimes(1);
32 | expect(callback).toHaveBeenCalledWith(null, response);
33 | });
34 |
35 | test('should call callback on error', async () => {
36 | saveFile.mockReturnValue(Promise.resolve({ response, error }));
37 |
38 | const callback = jest.fn();
39 |
40 | await save(event, context, callback);
41 |
42 | expect(saveFile).toHaveBeenCalledTimes(1);
43 | expect(saveFile).toHaveBeenCalledWith(JSON.stringify(payload));
44 | expect(callback).toHaveBeenCalledTimes(1);
45 | expect(callback).toHaveBeenCalledWith(error);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/services/file-service/src/handler.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
2 | import { saveFile } from './fileSaver';
3 |
4 | export const save: Handler = (
5 | event: APIGatewayEvent,
6 | context: Context,
7 | callback?: Callback,
8 | ) => {
9 | return saveFile(event.body).then(({ response, error }) => {
10 | if (error) {
11 | callback && callback(error);
12 | } else {
13 | callback && callback(null, response);
14 | }
15 | });
16 | };
17 |
--------------------------------------------------------------------------------
/services/file-service/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const slsw = require('serverless-webpack');
3 | const nodeExternals = require('webpack-node-externals');
4 |
5 | module.exports = {
6 | entry: slsw.lib.entries,
7 | target: 'node',
8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
9 | optimization: {
10 | // We no not want to minimize our code.
11 | minimize: false,
12 | },
13 | performance: {
14 | // Turn off size warnings for entry points
15 | hints: false,
16 | },
17 | devtool: 'nosources-source-map',
18 | externals: [nodeExternals()],
19 | resolve: {
20 | extensions: ['.js', '.json', '.ts', '.tsx'],
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.ts(x?)$/,
26 | use: [
27 | {
28 | loader: 'ts-loader',
29 | },
30 | ],
31 | },
32 | {
33 | test: /\.mjs$/,
34 | type: 'javascript/auto',
35 | },
36 | ],
37 | },
38 | output: {
39 | libraryTarget: 'commonjs2',
40 | path: path.join(__dirname, '.webpack'),
41 | filename: '[name].js',
42 | sourceMapFilename: '[file].map',
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/services/kinesis-service/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 |
8 | # Webpack directories
9 | .webpack
10 |
11 | config.*.json
12 | !config.example.json
13 |
14 | coverage
15 |
16 | junit.xml
17 |
18 | out.txt
--------------------------------------------------------------------------------
/services/kinesis-service/README.md:
--------------------------------------------------------------------------------
1 | # Kinesis Service
2 |
3 | ## Setup
4 |
5 | ```bash
6 | yarn install
7 | ```
8 |
--------------------------------------------------------------------------------
/services/kinesis-service/e2e/handler.chai.test.ts:
--------------------------------------------------------------------------------
1 | import awsTesting from 'aws-testing-library/lib/chai';
2 | import { invoke } from 'aws-testing-library/lib/utils/lambda';
3 | import chai = require('chai');
4 |
5 | chai.use(awsTesting);
6 |
7 | const { expect } = chai;
8 |
9 | describe('kinesis service e2e tests', () => {
10 | const region = 'us-east-1';
11 | const stream = 'kinesis-service-stream-dev';
12 |
13 | test('should put record in stream on lambda invoke', async () => {
14 | const body = {
15 | record: { message: 'message from test' },
16 | };
17 |
18 | const result = await invoke(region, 'kinesis-service-dev-queue', {
19 | body: JSON.stringify(body),
20 | });
21 |
22 | const { data } = JSON.parse(result.body);
23 |
24 | expect(data.message).to.be.equal('Record saved');
25 | await expect({ region, stream }).to.have.record(({ record }) => {
26 | return record.id === data.id && record.message === body.record.message;
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/services/kinesis-service/e2e/handler.test.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from 'aws-testing-library/lib/utils/lambda';
2 |
3 | describe('kinesis service e2e tests', () => {
4 | const region = 'us-east-1';
5 | const stream = 'kinesis-service-stream-dev';
6 |
7 | test('should put record in stream on lambda invoke', async () => {
8 | const body = {
9 | record: { message: 'message from test' },
10 | };
11 |
12 | const result = await invoke(region, 'kinesis-service-dev-queue', {
13 | body: JSON.stringify(body),
14 | });
15 |
16 | const { data } = JSON.parse(result.body);
17 |
18 | expect.assertions(2);
19 | expect(data.message).toEqual('Record saved');
20 | await expect({ region, stream }).toHaveRecord(({ record }) => {
21 | return record.id === data.id && record.message === body.record.message;
22 | });
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/services/kinesis-service/e2e/setup.ts:
--------------------------------------------------------------------------------
1 | import { deploy } from 'aws-testing-library/lib/utils/serverless';
2 |
3 | const deployDev = async () => {
4 | await deploy('dev');
5 | };
6 |
7 | export default deployDev;
8 |
--------------------------------------------------------------------------------
/services/kinesis-service/e2e/setupFrameworks.ts:
--------------------------------------------------------------------------------
1 | import 'aws-testing-library/lib/jest';
2 |
--------------------------------------------------------------------------------
/services/kinesis-service/e2e/setup_hook.js:
--------------------------------------------------------------------------------
1 | require('ts-node/register');
2 | module.exports = require('./setup').default;
3 |
--------------------------------------------------------------------------------
/services/kinesis-service/jest.config.e2e.js:
--------------------------------------------------------------------------------
1 | const config = require('./jest.config');
2 |
3 | module.exports = {
4 | ...config,
5 | roots: ['/e2e'],
6 | globalSetup: '/e2e/setup_hook.js',
7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts',
8 | };
9 |
--------------------------------------------------------------------------------
/services/kinesis-service/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8 | reporters: ['default', 'jest-junit'],
9 | testEnvironment: 'node',
10 | };
11 |
--------------------------------------------------------------------------------
/services/kinesis-service/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kinesis-service",
3 | "version": "0.0.1",
4 | "description": "Kinesis Service",
5 | "scripts": {
6 | "lint": "tslint 'src/**/*.ts'",
7 | "test": "jest",
8 | "test:watch": "jest --watch",
9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci",
10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci",
11 | "coverage": "jest --coverage",
12 | "deploy": "serverless deploy",
13 | "remove": "serverless remove",
14 | "prettier": "prettier --write src/**/*.ts",
15 | "prettier:ci": "prettier --list-different src/**/*.ts"
16 | },
17 | "dependencies": {
18 | "aws-sdk": "^2.449.0",
19 | "uuid": "^8.0.0"
20 | },
21 | "devDependencies": {
22 | "@anttiviljami/serverless-stack-output": "^0.3.1",
23 | "@types/aws-lambda": "^8.10.62",
24 | "@types/chai": "^4.2.12",
25 | "@types/jest": "^27.0.0",
26 | "@types/node": "^16.0.0",
27 | "@types/node-fetch": "^3.0.0",
28 | "aws-testing-library": "^2.0.0",
29 | "chai": "^4.2.0",
30 | "jest": "27.5.1",
31 | "mockdate": "^3.0.0",
32 | "serverless-plugin-tracing": "^2.0.0",
33 | "serverless-webpack": "^5.2.0",
34 | "ts-jest": "27.1.5",
35 | "ts-loader": "^9.0.0",
36 | "tslint": "^6.0.0",
37 | "tslint-config-prettier": "^1.18.0",
38 | "typescript": "^4.0.0",
39 | "webpack": "^5.0.0",
40 | "webpack-node-externals": "^3.0.0"
41 | },
42 | "author": "Erez Rokah",
43 | "license": "MIT"
44 | }
45 |
--------------------------------------------------------------------------------
/services/kinesis-service/serverless.yml:
--------------------------------------------------------------------------------
1 | service: kinesis-service
2 |
3 | plugins:
4 | - serverless-webpack
5 | - '@anttiviljami/serverless-stack-output'
6 | - serverless-plugin-tracing
7 |
8 | custom:
9 | defaultStage: dev
10 | currentStage: ${opt:stage, self:custom.defaultStage}
11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region}
12 | kinesisStream: kinesis-service-stream-${self:custom.currentStage}
13 |
14 | output:
15 | handler: ../../scripts/stackOutput.handler
16 |
17 | webpack:
18 | includeModules: true
19 | packager: yarn
20 |
21 | provider:
22 | name: aws
23 | runtime: nodejs8.10
24 | stage: ${self:custom.currentStage}
25 | region: ${self:custom.currentRegion}
26 |
27 | memorySize: 256
28 | logRetentionInDays: 7
29 |
30 | tracing: true # enable tracing
31 | iamRoleStatements:
32 | - Effect: 'Allow' # xray permissions (required)
33 | Action:
34 | - 'xray:PutTraceSegments'
35 | - 'xray:PutTelemetryRecords'
36 | Resource:
37 | - '*'
38 |
39 | - Effect: Allow
40 | Action:
41 | - kinesis:PutRecord
42 | Resource: 'arn:aws:kinesis:${opt:region, self:provider.region}:*:stream/${self:custom.kinesisStream}'
43 |
44 | functions:
45 | queue:
46 | handler: src/handler.queue
47 | events:
48 | - http:
49 | path: queue
50 | method: post
51 | cors: true
52 | environment:
53 | Stream: ${self:custom.kinesisStream}
54 |
55 | resources:
56 | Resources:
57 | KinesisStream:
58 | Type: AWS::Kinesis::Stream
59 | Properties:
60 | Name: ${self:custom.kinesisStream}
61 | ShardCount: 1
62 |
--------------------------------------------------------------------------------
/services/kinesis-service/src/handler.test.ts:
--------------------------------------------------------------------------------
1 | import { queue } from './handler';
2 |
3 | jest.mock('./kinesis');
4 |
5 | describe('handler', () => {
6 | const { queueEvent } = require('./kinesis');
7 | const response = {};
8 | const error = new Error('some error');
9 |
10 | const payload = {
11 | message: 'some message',
12 | };
13 | const event = { body: JSON.stringify(payload) };
14 | const context: any = null;
15 |
16 | beforeEach(() => {
17 | jest.clearAllMocks();
18 | });
19 |
20 | test('should call callback on response', async () => {
21 | queueEvent.mockReturnValue(Promise.resolve({ response, error: null }));
22 |
23 | const callback = jest.fn();
24 |
25 | await queue(event, context, callback);
26 |
27 | expect(queueEvent).toHaveBeenCalledTimes(1);
28 | expect(queueEvent).toHaveBeenCalledWith(JSON.stringify(payload));
29 | expect(callback).toHaveBeenCalledTimes(1);
30 | expect(callback).toHaveBeenCalledWith(null, response);
31 | });
32 |
33 | test('should call callback on error', async () => {
34 | queueEvent.mockReturnValue(Promise.resolve({ response, error }));
35 |
36 | const callback = jest.fn();
37 |
38 | await queue(event, context, callback);
39 |
40 | expect(queueEvent).toHaveBeenCalledTimes(1);
41 | expect(queueEvent).toHaveBeenCalledWith(JSON.stringify(payload));
42 | expect(callback).toHaveBeenCalledTimes(1);
43 | expect(callback).toHaveBeenCalledWith(error);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/services/kinesis-service/src/handler.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda';
2 | import { queueEvent } from './kinesis';
3 |
4 | export const queue: Handler = (
5 | event: APIGatewayEvent,
6 | context: Context,
7 | callback?: Callback,
8 | ) => {
9 | return queueEvent(event.body).then(({ response, error }) => {
10 | if (error) {
11 | callback && callback(error);
12 | } else {
13 | callback && callback(null, response);
14 | }
15 | });
16 | };
17 |
--------------------------------------------------------------------------------
/services/kinesis-service/src/kinesis.test.ts:
--------------------------------------------------------------------------------
1 | import MockDate = require('mockdate');
2 |
3 | jest.mock('aws-sdk', () => {
4 | const promise = jest.fn();
5 | const putRecord = jest.fn(() => ({ promise }));
6 | const Kinesis = jest.fn(() => ({ putRecord }));
7 | return { Kinesis };
8 | });
9 | jest.mock('uuid', () => {
10 | const v1 = jest.fn(() => '00000000000000000000000000');
11 | return { v1 };
12 | });
13 | jest.spyOn(console, 'log');
14 |
15 | process.env.Stream = 'Stream';
16 |
17 | MockDate.set('1948/1/1');
18 |
19 | describe('kinesis', () => {
20 | const headers = {
21 | 'Access-Control-Allow-Credentials': true,
22 | 'Access-Control-Allow-Origin': '*',
23 | };
24 |
25 | beforeEach(() => {
26 | jest.clearAllMocks();
27 | });
28 |
29 | const badDataResponse = (input: any) => ({
30 | body: JSON.stringify({
31 | input,
32 | message: 'Bad input data',
33 | }),
34 | headers,
35 | statusCode: 422,
36 | });
37 |
38 | const AWS = require('aws-sdk');
39 | const kinesis = AWS.Kinesis;
40 | const { v1: uuid } = require('uuid');
41 |
42 | test('should return response on empty body', async () => {
43 | const { queueEvent } = require('./kinesis');
44 |
45 | const body = '';
46 |
47 | const { response, error } = await queueEvent(body);
48 |
49 | expect(kinesis).toHaveBeenCalledTimes(1);
50 | expect(response).toEqual(badDataResponse(body));
51 | expect(error).toBeNull();
52 | });
53 |
54 | test('should return response on malformed body', async () => {
55 | const { queueEvent } = require('./kinesis');
56 |
57 | const body = 'invalid-json';
58 |
59 | const { response, error } = await queueEvent(body);
60 |
61 | expect(kinesis).toHaveBeenCalledTimes(1);
62 | expect(response).toEqual(badDataResponse(body));
63 | expect(error).toBeNull();
64 | expect(console.log).toHaveBeenCalledTimes(1);
65 | expect(console.log).toHaveBeenCalledWith(
66 | 'Invalid body',
67 | expect.any(SyntaxError),
68 | );
69 | });
70 |
71 | test('should return response on invalid record', async () => {
72 | const { queueEvent } = require('./kinesis');
73 |
74 | const record = { message: [] };
75 | const body = JSON.stringify({ record });
76 |
77 | const { response, error } = await queueEvent(body);
78 |
79 | expect(kinesis).toHaveBeenCalledTimes(1);
80 | expect(response).toEqual(badDataResponse(body));
81 | expect(error).toBeNull();
82 | });
83 |
84 | test('should return response on valid input', async () => {
85 | const { queueEvent } = require('./kinesis');
86 |
87 | const record = { message: 'some message' };
88 | const body = JSON.stringify({ record });
89 |
90 | const { response, error } = await queueEvent(body);
91 |
92 | expect(uuid).toHaveBeenCalledTimes(1);
93 | expect(AWS.Kinesis().putRecord).toHaveBeenCalledTimes(1);
94 | expect(AWS.Kinesis().putRecord).toHaveBeenCalledWith({
95 | Data: JSON.stringify({
96 | record: { message: record.message, id: uuid() },
97 | timestamp: new Date().toISOString(),
98 | }),
99 | PartitionKey: uuid(),
100 | StreamName: process.env.Stream,
101 | });
102 | expect(AWS.Kinesis().putRecord().promise).toHaveBeenCalledTimes(1);
103 |
104 | expect(response).toEqual({
105 | body: JSON.stringify({
106 | data: { message: 'Record saved', id: uuid() },
107 | input: body,
108 | }),
109 | headers,
110 | statusCode: 200,
111 | });
112 | expect(error).toBeNull();
113 | });
114 | });
115 |
--------------------------------------------------------------------------------
/services/kinesis-service/src/kinesis.ts:
--------------------------------------------------------------------------------
1 | import { Kinesis } from 'aws-sdk';
2 | import { v1 as uuid } from 'uuid';
3 |
4 | const StreamName = process.env.Stream || '';
5 |
6 | interface IRecord {
7 | message: string;
8 | }
9 |
10 | const headers = {
11 | /* Required for cookies, authorization headers with HTTPS */
12 | 'Access-Control-Allow-Credentials': true,
13 | /* Required for CORS support to work */
14 | 'Access-Control-Allow-Origin': '*',
15 | };
16 |
17 | const isString = (x: any): x is string => typeof x === 'string';
18 |
19 | const isRecord = (item: any) => {
20 | if (isString(item.message)) {
21 | return true;
22 | }
23 | return false;
24 | };
25 |
26 | export const queueEvent = async (body: string | null) => {
27 | const kinesis = new Kinesis();
28 |
29 | let item = null;
30 | if (body) {
31 | try {
32 | const parsed = JSON.parse(body);
33 | item = parsed.record as IRecord;
34 | } catch (e) {
35 | console.log('Invalid body', e);
36 | }
37 | }
38 |
39 | if (item && isRecord(item)) {
40 | const record = { message: item.message, id: uuid() };
41 | const params = {
42 | Data: JSON.stringify({
43 | record,
44 | timestamp: new Date().toISOString(),
45 | }),
46 | PartitionKey: record.id,
47 | StreamName,
48 | };
49 | await kinesis.putRecord(params).promise();
50 | const response = {
51 | body: JSON.stringify({
52 | data: { message: 'Record saved', id: record.id },
53 | input: body,
54 | }),
55 | headers,
56 | statusCode: 200,
57 | };
58 | return { response, error: null };
59 | } else {
60 | const response = {
61 | body: JSON.stringify({
62 | input: body,
63 | message: 'Bad input data',
64 | }),
65 | headers,
66 | statusCode: 422,
67 | };
68 | return { response, error: null };
69 | }
70 | };
71 |
--------------------------------------------------------------------------------
/services/kinesis-service/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const slsw = require('serverless-webpack');
3 | const nodeExternals = require('webpack-node-externals');
4 |
5 | module.exports = {
6 | entry: slsw.lib.entries,
7 | target: 'node',
8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
9 | optimization: {
10 | // We no not want to minimize our code.
11 | minimize: false,
12 | },
13 | performance: {
14 | // Turn off size warnings for entry points
15 | hints: false,
16 | },
17 | devtool: 'nosources-source-map',
18 | externals: [nodeExternals()],
19 | resolve: {
20 | extensions: ['.js', '.json', '.ts', '.tsx'],
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.ts(x?)$/,
26 | use: [
27 | {
28 | loader: 'ts-loader',
29 | },
30 | ],
31 | },
32 | {
33 | test: /\.mjs$/,
34 | type: 'javascript/auto',
35 | },
36 | ],
37 | },
38 | output: {
39 | libraryTarget: 'commonjs2',
40 | path: path.join(__dirname, '.webpack'),
41 | filename: '[name].js',
42 | sourceMapFilename: '[file].map',
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/services/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "sourceMap": true,
4 | "lib": ["esnext", "dom"],
5 | "module": "commonjs",
6 | "target": "es2017",
7 | "forceConsistentCasingInFileNames": true,
8 | "noImplicitReturns": true,
9 | "strict": true,
10 | "noUnusedLocals": true
11 | },
12 | "exclude": ["node_modules", "**/*.test.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/services/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "no-unused-expression": [true, "allow-fast-null-checks"],
5 | "no-console": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------