├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── .seeds ├── responses.json └── surveys.json ├── README.md ├── jest.config.json ├── package.json ├── resources ├── functions.yml └── resources.yml ├── sample-request.http ├── serverless.yml ├── src ├── core │ ├── index.ts │ ├── ports.ts │ ├── response.ts │ ├── survey.ts │ └── use-cases │ │ ├── index.ts │ │ ├── responses │ │ ├── create-response.ts │ │ └── get-by-form-id.ts │ │ └── surveys │ │ ├── create-survey.ts │ │ ├── get-survey-by-id.ts │ │ └── update-survey.ts ├── driven-adapters │ ├── index.ts │ ├── response-repository │ │ ├── response-repository.ts │ │ ├── response-transformation.ts │ │ └── types.ts │ └── survey-repository │ │ ├── survey-repository.ts │ │ ├── survey-transformation.ts │ │ └── types.ts ├── primary-adapters │ ├── response │ │ ├── create-response.ts │ │ └── get-by-form-id.ts │ └── survey │ │ ├── create-survey.ts │ │ ├── get-survey-by-id.ts │ │ └── update-survey.ts └── utils │ ├── api-gateway │ ├── __tests__ │ │ ├── parsers.test.ts │ │ └── response.test.ts │ ├── index.ts │ ├── parsers.ts │ └── response.ts │ ├── constants │ └── index.ts │ ├── dynamodb │ ├── document-client.ts │ ├── index.ts │ ├── types.ts │ └── utils.ts │ ├── errors │ ├── app-error.ts │ ├── input-error.ts │ ├── not-found-error.ts │ └── repository-error.ts │ ├── fp │ ├── __tests__ │ │ ├── either.test.ts │ │ ├── functions.test.ts │ │ ├── reader-task-either.test.ts │ │ └── task-either.test.ts │ ├── debug.ts │ ├── decode.ts │ ├── either.ts │ ├── functions.ts │ ├── index.ts │ ├── misc.ts │ ├── reader-task-either.ts │ └── task-either.ts │ └── types.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:@typescript-eslint/eslint-recommended" 6 | ], 7 | "plugins": ["@typescript-eslint"], 8 | "parser": "@typescript-eslint/parser", 9 | "env": { "node": true, "es6": true }, 10 | "parserOptions": { 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/no-unused-vars": [ 15 | "error", 16 | { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } 17 | ], 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "@typescript-eslint/member-delimiter-style": "off", 20 | "@typescript-eslint/interface-name-prefix": "off", 21 | "@typescript-eslint/explicit-function-return-type": "off", 22 | "@typescript-eslint/no-use-before-define": "off", 23 | "@typescript-eslint/camelcase": "off", 24 | "@typescript-eslint/no-var-requires": "error", 25 | "@typescript-eslint/quotes": "off", 26 | "@typescript-eslint/no-non-null-assertion": "error", 27 | "@typescript-eslint/explicit-module-boundary-types": "off", 28 | "object-shorthand": ["error", "always"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .serverless 107 | .webpack 108 | .build/ 109 | sandbox.ts -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "bracketSpacing": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "trailingComma": "all", 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.seeds/responses.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "PK": "survey_1234", 4 | "SK": "response_test@test.com", 5 | "email": "test@test.com", 6 | "answers": [ 7 | { 8 | "questionId": "1", 9 | "value": ["test"] 10 | } 11 | ] 12 | }, 13 | { 14 | "PK": "survey_1234", 15 | "SK": "response_test1@test.com", 16 | "email": "test1@test.com", 17 | "answers": [ 18 | { 19 | "questionId": "1", 20 | "value": ["something"] 21 | } 22 | ] 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /.seeds/surveys.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "PK": "survey_1234", 4 | "SK": "#survey", 5 | "closeDate": "2020-01-01", 6 | "questions": [{ "id": "abc", "type": "select", "options": ["option1", "option2"] }] 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Functional programming way of hexagonal architecture 2 | 3 | Hexagonal architecture for survey application. There are `Survey` and `Response` 4 | 5 | --- 6 | 7 | ## Goals 8 | 9 | - show examples with fp-ts 10 | - keep it simple 11 | 12 | --- 13 | 14 | ## Functionalities 15 | 16 | - user can create survey 17 | - user can update survey 18 | - user can get survey by form id 19 | 20 | - user can create response for specific survey 21 | - user can get all responses for specific survey 22 | 23 | --- 24 | 25 | ## API 26 | 27 | sample-request.http can be used for test requests with VSCode REST Client extension. 28 | 29 | ### Survey 30 | 31 | GET: /surveys/{formId} 32 | response: Survey 33 | 34 | POST: /surveys 35 | response: Survey 36 | 37 | PUT: /surveys/{formId} 38 | response: Survey 39 | 40 | ### Response 41 | 42 | GET: /surveys/{formId}/responses 43 | response: Response[] 44 | 45 | POST: /responses 46 | response: Response 47 | 48 | ``` 49 | type Survey = { 50 | id: string 51 | closeDate: string 52 | questions: { 53 | id: string 54 | type: "select" | "multiple-choice" | "open" 55 | options: string[] 56 | }[] 57 | } 58 | 59 | ``` 60 | 61 | ``` 62 | type Response = { 63 | formId: string 64 | email: string 65 | answers: { 66 | questionId: string 67 | value: string[] 68 | }[]; 69 | } 70 | ``` 71 | 72 | --- 73 | 74 | ## How to run 75 | 76 | - serverless-dynamodb-local is used, so Java Runtime Engine (JRE) version 6.x or newer is required (https://www.serverless.com/plugins/serverless-dynamodb-local) 77 | - run `yarn install` 78 | - run `yarn dev` 79 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "node", 4 | "resetMocks": true, 5 | "testMatch": ["**/__tests__/**/*.test.ts"], 6 | "globals": { "diagnostics": { "warnOnly": true } }, 7 | "moduleNameMapper": { 8 | "^@core/(.+)": "/src/core/$1", 9 | "^@utils/(.+)": "/src/utils/$1", 10 | "^@resources/(.+)": "/src/driven-adapters/$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fp-hexagonal-architecture-examples", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:Hajime-Suzuki/functional-programming-hexagonal-architecture.git", 6 | "author": "Hajime-Suzuki ", 7 | "license": "MIT", 8 | "scripts": { 9 | "deploy:dev": "sls deploy --stage dev", 10 | "deploy:prod": "sls deploy --stage prod", 11 | "dev": "sls offline start", 12 | "dynamodb:stop": "lsof -ti:8000 | xargs kill", 13 | "dynamodb:start": "sls dynamodb start", 14 | "test:watch": "jest --watch --runInBand", 15 | "check-updates": "ncu" 16 | }, 17 | "devDependencies": { 18 | "@types/aws-lambda": "^8.10.63", 19 | "@types/jest": "^26.0.14", 20 | "@types/node": "^14.11.2", 21 | "@types/shortid": "^0.0.29", 22 | "@types/validator": "^13.1.0", 23 | "@typescript-eslint/eslint-plugin": "^4.3.0", 24 | "@typescript-eslint/parser": "^4.3.0", 25 | "aws-sdk": "^2.766.0", 26 | "eslint": "^7.10.0", 27 | "eslint-config-prettier": "^6.12.0", 28 | "eslint-plugin-prettier": "^3.1.4", 29 | "fork-ts-checker-webpack-plugin": "^5.2.0", 30 | "husky": "^4.3.0", 31 | "jest": "^26.4.2", 32 | "lint-staged": "^10.4.0", 33 | "npm-check-updates": "^9.0.3", 34 | "prettier": "^2.1.2", 35 | "serverless": "^2.4.0", 36 | "serverless-dynamodb-local": "^0.2.39", 37 | "serverless-offline": "^6.8.0", 38 | "serverless-pseudo-parameters": "^2.5.0", 39 | "serverless-webpack": "^5.3.5", 40 | "ts-jest": "^26.4.1", 41 | "ts-loader": "^8.0.4", 42 | "typescript": "^4.0.3", 43 | "webpack": "^4.44.2", 44 | "webpack-node-externals": "^2.5.2" 45 | }, 46 | "dependencies": { 47 | "date-fns": "^2.16.1", 48 | "fp-ts": "^2.8.3", 49 | "fp-ts-contrib": "^0.1.21", 50 | "io-ts": "^2.2.10", 51 | "shortid": "^2.2.15", 52 | "validator": "^13.1.17" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /resources/functions.yml: -------------------------------------------------------------------------------- 1 | create-survey: 2 | handler: src/primary-adapters/survey/create-survey.handler 3 | events: 4 | - http: 5 | path: /surveys 6 | method: POST 7 | 8 | get-survey: 9 | handler: src/primary-adapters/survey/get-survey-by-id.handler 10 | events: 11 | - http: 12 | path: /surveys/{formId} 13 | method: GET 14 | 15 | update-survey: 16 | handler: src/primary-adapters/survey/update-survey.handler 17 | events: 18 | - http: 19 | path: /surveys/{formId} 20 | method: PUT 21 | 22 | get-responses-by-form-id: 23 | handler: src/primary-adapters/response/get-by-form-id.handler 24 | events: 25 | - http: 26 | path: /surveys/{formId}/responses 27 | method: GET 28 | 29 | create-response: 30 | handler: src/primary-adapters/response/create-response.handler 31 | events: 32 | - http: 33 | path: /responses 34 | method: POST 35 | -------------------------------------------------------------------------------- /resources/resources.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | table: 3 | Type: AWS::DynamoDB::Table 4 | Properties: 5 | TableName: ${self:provider.environment.tableName} 6 | AttributeDefinitions: 7 | - AttributeName: PK 8 | AttributeType: S 9 | - AttributeName: SK 10 | AttributeType: S 11 | 12 | KeySchema: 13 | - AttributeName: PK 14 | KeyType: HASH 15 | - AttributeName: SK 16 | KeyType: RANGE 17 | 18 | BillingMode: PAY_PER_REQUEST 19 | -------------------------------------------------------------------------------- /sample-request.http: -------------------------------------------------------------------------------- 1 | # Survey 2 | GET http://localhost:4000/dev/surveys/1234 3 | 4 | ### 5 | 6 | POST http://localhost:4000/dev/surveys 7 | Content-Type: application/json 8 | 9 | { 10 | "closeDate": "2025-10-15", 11 | "questions": [ 12 | { "id": "abc", "type": "select", "options": ["option1", "option2"] } 13 | ] 14 | } 15 | ### 16 | PUT http://localhost:4000/dev/surveys/1234 17 | Content-Type: application/json 18 | 19 | { 20 | "closeDate": "2020-10-11", 21 | "questions": [ 22 | { "id": "abc", "type": "select", "options": ["option100", "option2"] } 23 | ] 24 | } 25 | 26 | ### 27 | 28 | # Response 29 | 30 | GET http://localhost:4000/dev/surveys/1234/responses 31 | 32 | ### 33 | POST http://localhost:4000/dev/responses 34 | Content-Type: application/json 35 | 36 | { 37 | "formId": "1234", 38 | "email": "test@test2.com", 39 | "answers": [ 40 | {"questionId": "1", "value": ["test"] } 41 | ] 42 | } -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: 2 | name: fp-ts-survey 3 | 4 | functions: ${file(./resources/functions.yml)} 5 | resources: ${file(./resources/resources.yml)} 6 | 7 | provider: 8 | name: aws 9 | region: 'eu-central-1' 10 | stage: ${opt:stage, 'dev'} 11 | runtime: nodejs12.x 12 | environment: 13 | tableName: ${self:provider.stage}-${self:service.name}__table 14 | 15 | iamRoleStatements: 16 | - Effect: Allow 17 | Action: 18 | - 'dynamodb:Query' 19 | - 'dynamodb:GetItem' 20 | - 'dynamodb:PutItem' 21 | - 'dynamodb:UpdateItem' 22 | - 'dynamodb:DeleteItem' 23 | Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:*' 24 | 25 | custom: 26 | serverless-offline: 27 | httpPort: 4000 28 | 29 | webpack: 30 | includeModules: 31 | forceExclude: 32 | - aws-sdk 33 | 34 | dynamodb: 35 | stages: 36 | - dev 37 | start: 38 | port: 8000 39 | inMemory: true 40 | migrate: true 41 | seed: true 42 | seed: 43 | dev: 44 | sources: 45 | - table: ${self:provider.environment.tableName} 46 | sources: ['.seeds/surveys.json', '.seeds/responses.json'] 47 | 48 | package: 49 | individually: true 50 | 51 | plugins: 52 | - serverless-dynamodb-local 53 | - serverless-webpack 54 | - serverless-offline 55 | - serverless-pseudo-parameters 56 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './survey' 2 | export * from './response' 3 | -------------------------------------------------------------------------------- /src/core/ports.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError } from '@utils/errors/not-found-error' 2 | import { RepositoryError } from '@utils/errors/repository-error' 3 | import { ReaderTaskEither, Option } from '@utils/fp' 4 | import { DocumentClient } from 'aws-sdk/clients/dynamodb' 5 | 6 | import { Survey, Response, UpdateSurveyInput } from '.' 7 | 8 | export type CommonPorts = DBPorts 9 | 10 | export type DBPorts = { 11 | db: { 12 | client: DocumentClient 13 | tableName: string 14 | } 15 | } 16 | 17 | export type SurveyRepository = { 18 | getByFormId: ( 19 | formId: string, 20 | ) => ReaderTaskEither 21 | create: (data: Survey) => ReaderTaskEither 22 | update: ( 23 | id: Survey['id'], 24 | ) => (data: UpdateSurveyInput) => ReaderTaskEither 25 | } 26 | 27 | export type ResponseRepository = { 28 | create: (data: Response) => ReaderTaskEither 29 | getByFormId: (formId: string) => ReaderTaskEither 30 | getOne: (params: { 31 | formId: string 32 | email: string 33 | }) => ReaderTaskEither> 34 | } 35 | -------------------------------------------------------------------------------- /src/core/response.ts: -------------------------------------------------------------------------------- 1 | import { decodeWith } from '@utils/fp' 2 | import * as D from 'io-ts/Decoder' 3 | import validator from 'validator' 4 | 5 | type Answer = D.TypeOf 6 | const Answer = D.type({ 7 | questionId: D.string, 8 | value: D.array(D.string), 9 | }) 10 | 11 | const Email: D.Decoder = { 12 | decode: str => { 13 | if (typeof str !== 'string') return D.failure(str, 'string') 14 | return validator.isEmail(str as any) ? D.success(str as string) : D.failure(str, 'email') 15 | }, 16 | } 17 | 18 | export type Response = D.TypeOf 19 | const Response = D.type({ 20 | formId: D.string, 21 | email: Email, 22 | answers: D.array(Answer), 23 | }) 24 | 25 | export const mkResponse = decodeWith(Response) 26 | -------------------------------------------------------------------------------- /src/core/survey.ts: -------------------------------------------------------------------------------- 1 | import { decodeWith, E } from '@utils/fp' 2 | import { isFuture, isValid } from 'date-fns' 3 | import { flow } from 'fp-ts/lib/function' 4 | import * as D from 'io-ts/Decoder' 5 | import * as shortId from 'shortid' 6 | 7 | type QuestionType = D.TypeOf 8 | const QuestionType = D.literal('select', 'multiple-choice', 'open') 9 | 10 | type Question = D.TypeOf 11 | const Question = D.type({ 12 | id: D.string, 13 | type: QuestionType, 14 | options: D.array(D.string), 15 | }) 16 | 17 | export type Survey = D.TypeOf 18 | export const Survey = D.type({ 19 | id: D.string, 20 | closeDate: D.string, 21 | questions: D.array(Question), 22 | }) 23 | 24 | const FutureDate: D.Decoder = { 25 | decode: str => { 26 | if (typeof str !== 'string') return D.failure(str, 'string') 27 | D.string.decode(str) 28 | const mayDate = new Date(str) 29 | 30 | if (!isValid(mayDate)) return D.failure(str, 'valid date') 31 | return isFuture(mayDate) ? D.success(str) : D.failure(str, '> today') 32 | }, 33 | } 34 | 35 | const CreateSurveyInput = D.type({ 36 | closeDate: FutureDate, 37 | questions: D.array(Question), 38 | }) 39 | 40 | type CreateSurveyInput = D.TypeOf 41 | 42 | export const mkSurveyFromInput = flow( 43 | decodeWith(CreateSurveyInput), 44 | E.map(v => ({ id: shortId.generate(), ...v })), 45 | ) 46 | 47 | const UpdateSurveyInput = D.partial({ 48 | closeDate: FutureDate, 49 | questions: D.array(Question), 50 | }) 51 | export type UpdateSurveyInput = D.TypeOf 52 | 53 | export const mkUpdateSurveyInput = decodeWith(UpdateSurveyInput) 54 | -------------------------------------------------------------------------------- /src/core/use-cases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './surveys/create-survey' 2 | export * from './surveys/get-survey-by-id' 3 | export * from './surveys/update-survey' 4 | -------------------------------------------------------------------------------- /src/core/use-cases/responses/create-response.ts: -------------------------------------------------------------------------------- 1 | import { mkResponse, Response } from '@core' 2 | import { CommonPorts } from '@core/ports' 3 | import { surveyRepo } from '@resources' 4 | import { responseRepo } from '@resources/response-repository/response-repository' 5 | import { AppError, toAppError } from '@utils/errors/app-error' 6 | import { NotFoundError } from '@utils/errors/not-found-error' 7 | import { RepositoryError } from '@utils/errors/repository-error' 8 | import { get, O, ReaderTaskEither, RTE } from '@utils/fp' 9 | import { flow, pipe } from 'fp-ts/lib/function' 10 | 11 | const _ = RTE 12 | 13 | type Env = CommonPorts 14 | 15 | type Payload = { 16 | input: any 17 | } 18 | 19 | type UseCase = ( 20 | payload: Payload, 21 | ) => ReaderTaskEither 22 | 23 | export const createResponse: UseCase = flow( 24 | flow(get('input'), mkResponse, _.fromEither), 25 | _.chain(decoded => 26 | pipe( 27 | responseRepo.getOne(decoded), 28 | _.chain( 29 | O.fold( 30 | () => _.right(undefined), 31 | () => _.left(toAppError('user has already sent')), 32 | ), 33 | ), 34 | _.chain(() => surveyRepo.getByFormId(decoded.formId)), 35 | _.chain(() => responseRepo.create(decoded)), 36 | ), 37 | ), 38 | ) 39 | -------------------------------------------------------------------------------- /src/core/use-cases/responses/get-by-form-id.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@core' 2 | import { CommonPorts } from '@core/ports' 3 | import { responseRepo } from '@resources/response-repository/response-repository' 4 | import { AppError } from '@utils/errors/app-error' 5 | import { RepositoryError } from '@utils/errors/repository-error' 6 | import { get, ReaderTaskEither, withEnv } from '@utils/fp' 7 | import { flow } from 'fp-ts/lib/function' 8 | 9 | type Env = CommonPorts 10 | 11 | type Payload = { 12 | formId: string 13 | } 14 | 15 | type UseCase = (payload: Payload) => ReaderTaskEither 16 | 17 | export const getByFormId: UseCase = flow(get('formId'), responseRepo.getByFormId, withEnv) 18 | -------------------------------------------------------------------------------- /src/core/use-cases/surveys/create-survey.ts: -------------------------------------------------------------------------------- 1 | import { surveyRepo } from '@resources' 2 | import { AppError } from '@utils/errors/app-error' 3 | import { InputError } from '@utils/errors/input-error' 4 | import { RepositoryError } from '@utils/errors/repository-error' 5 | import { get, ReaderTaskEither, RTE } from '@utils/fp' 6 | import { flow } from 'fp-ts/lib/function' 7 | import { CommonPorts } from '../../ports' 8 | import { mkSurveyFromInput, Survey } from '../../survey' 9 | 10 | const _ = RTE 11 | type Env = CommonPorts 12 | type Payload = { 13 | input: any 14 | } 15 | 16 | type UseCase = ( 17 | payload: Payload, 18 | ) => ReaderTaskEither 19 | 20 | export const createSurvey: UseCase = flow( 21 | get('input'), 22 | mkSurveyFromInput, 23 | _.fromEither, 24 | _.chainW(surveyRepo.create), 25 | ) 26 | -------------------------------------------------------------------------------- /src/core/use-cases/surveys/get-survey-by-id.ts: -------------------------------------------------------------------------------- 1 | import { Survey } from '@core/survey' 2 | import { CommonPorts } from '@core/ports' 3 | import { surveyRepo } from '@resources' 4 | import { AppError } from '@utils/errors/app-error' 5 | import { RepositoryError } from '@utils/errors/repository-error' 6 | import { get, ReaderTaskEither, withEnv } from '@utils/fp' 7 | import { flow } from 'fp-ts/lib/function' 8 | 9 | type Env = CommonPorts 10 | 11 | type Payload = { 12 | formId: string 13 | } 14 | 15 | type UseCase = (payload: Payload) => ReaderTaskEither 16 | 17 | export const getSurveyById: UseCase = flow(get('formId'), surveyRepo.getByFormId, withEnv) 18 | -------------------------------------------------------------------------------- /src/core/use-cases/surveys/update-survey.ts: -------------------------------------------------------------------------------- 1 | import { Survey, mkUpdateSurveyInput } from '@core/survey' 2 | import { CommonPorts } from '@core/ports' 3 | import { surveyRepo } from '@resources' 4 | import { InputError } from '@utils/errors/input-error' 5 | import { NotFoundError } from '@utils/errors/not-found-error' 6 | import { RepositoryError } from '@utils/errors/repository-error' 7 | import { constInput, get, ReaderTaskEither, RTE } from '@utils/fp' 8 | import { Do } from 'fp-ts-contrib/lib/Do' 9 | import { flow, pipe } from 'fp-ts/lib/function' 10 | 11 | const _ = RTE 12 | 13 | type Env = CommonPorts 14 | type Payload = { formId: string; input: unknown } 15 | 16 | type UseCase = ( 17 | payload: Payload, 18 | ) => ReaderTaskEither 19 | 20 | export const updateSurvey: UseCase = ({ input, formId }) => 21 | pipe( 22 | _.fromEither(mkUpdateSurveyInput(input)), 23 | _.chain(constInput(surveyRepo.getByFormId(formId))), 24 | _.chain(surveyRepo.update(formId)), 25 | ) 26 | 27 | const _updateSurveyWithDoNotation: UseCase = payload => 28 | Do(RTE.readerTaskEither) 29 | .bind('input', _.fromEither(mkUpdateSurveyInput(payload.input))) 30 | .do(surveyRepo.getByFormId(payload.formId)) 31 | .bindL('res', flow(get('input'), surveyRepo.update(payload.formId))) 32 | .return(({ res }) => { 33 | return res 34 | }) 35 | -------------------------------------------------------------------------------- /src/driven-adapters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './survey-repository/survey-repository' 2 | -------------------------------------------------------------------------------- /src/driven-adapters/response-repository/response-repository.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@core' 2 | import { ResponseRepository } from '@core/ports' 3 | import { WithClient, withClient as _withClient } from '@utils/dynamodb' 4 | import { RepositoryError } from '@utils/errors/repository-error' 5 | import { A, get, O, Option, TaskEither, TE } from '@utils/fp' 6 | import { flow, pipe } from 'fp-ts/lib/function' 7 | import { 8 | fromResponse, 9 | mkDBKey, 10 | mkGetByFormIdCondition, 11 | toResponse, 12 | } from './response-transformation' 13 | import { ResponseClient } from './types' 14 | 15 | const _ = TE 16 | const withClient: WithClient = _withClient 17 | type Client = ResponseClient 18 | type Repo = ResponseRepository 19 | 20 | const getOne = (data: { formId: string; email: string }) => ( 21 | client: Client, 22 | ): TaskEither> => 23 | pipe( 24 | client.get({ Key: mkDBKey(data) }), 25 | _.map(get('Item')), 26 | _.map(O.fromNullable), 27 | _.map(O.map(toResponse)), 28 | ) 29 | 30 | const getByFormId = (formId: string) => (client: Client) => 31 | pipe(client.query(mkGetByFormIdCondition(formId)), _.map(get('Items')), _.map(A.map(toResponse))) 32 | 33 | const create = (data: Response) => (client: Client) => 34 | pipe( 35 | client.create(fromResponse(data)), 36 | _.map(() => data), 37 | ) 38 | 39 | export const responseRepo: Repo = { 40 | create: flow(create, withClient), 41 | getByFormId: flow(getByFormId, withClient), 42 | getOne: flow(getOne, withClient), 43 | } 44 | -------------------------------------------------------------------------------- /src/driven-adapters/response-repository/response-transformation.ts: -------------------------------------------------------------------------------- 1 | import { Response } from '@core' 2 | import { QueryPayload } from '@utils/dynamodb' 3 | import { 4 | fromPK as fromSurveyPK, 5 | toPK as toSurveyPK, 6 | } from '../survey-repository/survey-transformation' 7 | import { DBKey, DBResponse, PK, SK } from './types' 8 | 9 | export const toResponse = (data: DBResponse): Response => { 10 | return { 11 | formId: fromPK(data.PK), 12 | email: data.email, 13 | answers: data.answers, 14 | } 15 | } 16 | 17 | export const fromResponse = (data: Response): DBResponse => { 18 | return { 19 | PK: toPK(data.formId), 20 | SK: toSK(data.email), 21 | email: data.email, 22 | answers: data.answers, 23 | } 24 | } 25 | 26 | const SKPrefix = 'response_' 27 | const toPK = (formId: Response['formId']): PK => toSurveyPK(formId) 28 | const fromPK = fromSurveyPK 29 | const toSK = (email: Response['email']): SK => SKPrefix + email 30 | 31 | export const mkDBKey = ({ formId, email }: Pick): DBKey => ({ 32 | PK: toPK(formId), 33 | SK: toSK(email), 34 | }) 35 | 36 | export const mkGetByFormIdCondition = (formId: string): QueryPayload => ({ 37 | KeyConditionExpression: `PK = :PK and begins_with (SK, :SKPrefix)`, 38 | ExpressionAttributeValues: { ':PK': toPK(formId), ':SKPrefix': SKPrefix }, 39 | }) 40 | -------------------------------------------------------------------------------- /src/driven-adapters/response-repository/types.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@utils/dynamodb' 2 | import { Response } from '@core/survey' 3 | export type ResponseClient = Client 4 | 5 | export type PK = string 6 | export type SK = string 7 | export type DBKey = { PK: PK; SK: SK } 8 | 9 | export type DBResponse = { 10 | PK: PK 11 | SK: SK 12 | } & Pick 13 | -------------------------------------------------------------------------------- /src/driven-adapters/survey-repository/survey-repository.ts: -------------------------------------------------------------------------------- 1 | import { Survey, UpdateSurveyInput } from '@core/survey' 2 | import { SurveyRepository } from '@core/ports' 3 | import { mkDBUpdateInput, WithClient, withClient as _withClient } from '@utils/dynamodb' 4 | import { toNotFoundError } from '@utils/errors/not-found-error' 5 | import { E, get, TE } from '@utils/fp' 6 | import { flow, pipe } from 'fp-ts/lib/function' 7 | import { fromSurvey, mkDBKey, toSurvey } from './survey-transformation' 8 | import { SurveyClient } from './types' 9 | 10 | const _ = TE 11 | const withClient: WithClient = _withClient 12 | 13 | const getByFormId = (formId: string) => (client: SurveyClient) => 14 | pipe( 15 | client.get({ Key: mkDBKey(formId) }), 16 | _.map(get('Item')), 17 | _.chainW(flow(E.fromNullable(toNotFoundError(`survey ${formId} not found`)), _.fromEither)), 18 | _.map(toSurvey), 19 | ) 20 | 21 | const create = (data: Survey) => (client: SurveyClient) => 22 | pipe( 23 | client.create(fromSurvey(data)), 24 | _.map(() => data), 25 | ) 26 | 27 | const update = (formId: string) => (data: UpdateSurveyInput) => (client: SurveyClient) => 28 | pipe( 29 | client.update( 30 | mkDBKey(formId), 31 | mkDBUpdateInput({ 32 | ...(data.closeDate && { closeDate: data.closeDate }), 33 | ...(data.questions && { questions: data.questions }), 34 | }), 35 | ), 36 | TE.map(get('Attributes')), 37 | TE.map(toSurvey), 38 | ) 39 | 40 | export const surveyRepo: SurveyRepository = { 41 | getByFormId: flow(getByFormId, withClient), 42 | create: flow(create, withClient), 43 | update: formId => flow(update(formId), withClient), 44 | } 45 | -------------------------------------------------------------------------------- /src/driven-adapters/survey-repository/survey-transformation.ts: -------------------------------------------------------------------------------- 1 | import { Survey } from '@core/survey' 2 | import { DBKey, DBSurvey, PK, SK } from './types' 3 | 4 | export const toSurvey = (data: DBSurvey): Survey => ({ 5 | id: fromPK(data.PK), 6 | closeDate: data.closeDate, 7 | questions: data.questions as Survey['questions'], 8 | }) 9 | 10 | export const fromSurvey = (data: Survey): DBSurvey => ({ 11 | PK: toPK(data.id), 12 | SK: toSK(), 13 | closeDate: data.closeDate, 14 | questions: data.questions, 15 | }) 16 | 17 | export const toPK = (id: Survey['id']): PK => 'survey_' + id 18 | export const fromPK = (id: PK) => id.replace('survey_', '') 19 | const toSK = (): SK => '#survey' 20 | 21 | export const mkDBKey = (id: Survey['id']): DBKey => ({ PK: toPK(id), SK: toSK() }) 22 | -------------------------------------------------------------------------------- /src/driven-adapters/survey-repository/types.ts: -------------------------------------------------------------------------------- 1 | import { Survey } from '@core/survey' 2 | import { Client } from '@utils/dynamodb/types' 3 | import { DBUpdateInput } from '@utils/dynamodb/utils' 4 | 5 | export type SurveyClient = Client 6 | 7 | export type PK = string 8 | export type SK = '#survey' 9 | export type DBKey = { PK: PK; SK: SK } 10 | 11 | export type DBSurvey = { 12 | PK: PK 13 | SK: SK 14 | } & Pick 15 | -------------------------------------------------------------------------------- /src/primary-adapters/response/create-response.ts: -------------------------------------------------------------------------------- 1 | import { createResponse } from '@core/use-cases/responses/create-response' 2 | import { decodeBody, toApiGatewayResponse } from '@utils/api-gateway' 3 | import { TABLE_NAME } from '@utils/constants' 4 | import { dbClient } from '@utils/dynamodb/document-client' 5 | import { E, RTE, singleton } from '@utils/fp' 6 | import { APIGatewayEvent } from 'aws-lambda' 7 | import { pipe } from 'fp-ts/lib/function' 8 | 9 | const _ = RTE 10 | export const handler = async (event: APIGatewayEvent) => { 11 | const env = { 12 | db: { 13 | client: dbClient, 14 | tableName: TABLE_NAME, 15 | }, 16 | } 17 | 18 | const res = pipe( 19 | _.fromEither(pipe(decodeBody(event), E.map(singleton('input')))), 20 | _.chain(createResponse), 21 | _.map(singleton('responses')), 22 | ) 23 | 24 | return pipe(await res(env)(), toApiGatewayResponse()) 25 | } 26 | -------------------------------------------------------------------------------- /src/primary-adapters/response/get-by-form-id.ts: -------------------------------------------------------------------------------- 1 | import { getByFormId } from '@core/use-cases/responses/get-by-form-id' 2 | import { getPathParams, toApiGatewayResponse } from '@utils/api-gateway' 3 | import { TABLE_NAME } from '@utils/constants' 4 | import { dbClient } from '@utils/dynamodb/document-client' 5 | import { RTE, singleton } from '@utils/fp' 6 | import { APIGatewayEvent } from 'aws-lambda' 7 | import { pipe } from 'fp-ts/lib/function' 8 | 9 | const _ = RTE 10 | export const handler = async (event: APIGatewayEvent) => { 11 | const env = { 12 | db: { 13 | client: dbClient, 14 | tableName: TABLE_NAME, 15 | }, 16 | } 17 | 18 | const res = pipe( 19 | _.fromEither(getPathParams(event)), 20 | _.chain(getByFormId), 21 | _.map(singleton('responses')), 22 | ) 23 | 24 | return pipe(await res(env)(), toApiGatewayResponse()) 25 | } 26 | -------------------------------------------------------------------------------- /src/primary-adapters/survey/create-survey.ts: -------------------------------------------------------------------------------- 1 | import { createSurvey } from '@core/use-cases' 2 | import { apiGatewayResponse, decodeBody, toApiGatewayResponse } from '@utils/api-gateway' 3 | import { TABLE_NAME } from '@utils/constants' 4 | import { dbClient } from '@utils/dynamodb/document-client' 5 | import { RTE, singleton } from '@utils/fp' 6 | import { APIGatewayEvent } from 'aws-lambda' 7 | import { flow } from 'fp-ts/lib/function' 8 | import { pipe } from 'fp-ts/lib/function' 9 | 10 | const _ = RTE 11 | export const handler = async (event: APIGatewayEvent) => { 12 | const env = { 13 | db: { 14 | client: dbClient, 15 | tableName: TABLE_NAME, 16 | }, 17 | } 18 | 19 | const res = pipe( 20 | _.fromEither(decodeBody(event)), 21 | _.chain(flow(singleton('input'), createSurvey)), 22 | _.map(singleton('survey')), 23 | ) 24 | 25 | return pipe( 26 | await res(env)(), 27 | toApiGatewayResponse({ customSuccessResponse: apiGatewayResponse.created }), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/primary-adapters/survey/get-survey-by-id.ts: -------------------------------------------------------------------------------- 1 | import { getSurveyById } from '@core/use-cases' 2 | import { getPathParams, toApiGatewayResponse } from '@utils/api-gateway' 3 | import { TABLE_NAME } from '@utils/constants' 4 | import { dbClient } from '@utils/dynamodb/document-client' 5 | import { RTE, singleton } from '@utils/fp' 6 | import { APIGatewayEvent } from 'aws-lambda' 7 | import { pipe } from 'fp-ts/lib/function' 8 | 9 | const _ = RTE 10 | export const handler = async (event: APIGatewayEvent) => { 11 | const env = { 12 | db: { 13 | client: dbClient, 14 | tableName: TABLE_NAME, 15 | }, 16 | } 17 | const params = getPathParams<'formId'>(event) 18 | 19 | const res = pipe(_.fromEither(params), _.chain(getSurveyById), _.map(singleton('survey'))) 20 | return pipe(await res(env)(), toApiGatewayResponse()) 21 | } 22 | -------------------------------------------------------------------------------- /src/primary-adapters/survey/update-survey.ts: -------------------------------------------------------------------------------- 1 | import { updateSurvey } from '@core/use-cases' 2 | import { decodeBody, getPathParams, toApiGatewayResponse } from '@utils/api-gateway' 3 | import { TABLE_NAME } from '@utils/constants' 4 | import { dbClient } from '@utils/dynamodb/document-client' 5 | import { E, get, RTE, singleton } from '@utils/fp' 6 | import { APIGatewayEvent } from 'aws-lambda' 7 | import { sequenceS } from 'fp-ts/lib/Apply' 8 | import { pipe } from 'fp-ts/lib/function' 9 | 10 | const _ = RTE 11 | export const handler = async (event: APIGatewayEvent) => { 12 | const env = { 13 | db: { 14 | client: dbClient, 15 | tableName: TABLE_NAME, 16 | }, 17 | } 18 | 19 | const res = pipe( 20 | _.fromEither( 21 | sequenceS(E.either)({ 22 | formId: pipe(getPathParams<'formId'>(event), E.map(get('formId'))), 23 | input: decodeBody(event), 24 | }), 25 | ), 26 | _.chain(updateSurvey), 27 | _.map(singleton('survey')), 28 | ) 29 | 30 | return pipe(await res(env)(), toApiGatewayResponse()) 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/api-gateway/__tests__/parsers.test.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent } from 'aws-lambda' 2 | import { isLeft, isRight } from 'fp-ts/lib/Either' 3 | import { decodeBody } from '../parsers' 4 | 5 | describe('api-gateway', () => { 6 | describe('parsers.fp', () => { 7 | describe('decodeBody', () => { 8 | test('returns left if body is empty', () => { 9 | const event = { body: null } as APIGatewayEvent 10 | const decoded = decodeBody(event) 11 | expect(isLeft(decoded)).toBe(true) 12 | }) 13 | 14 | test('correctly decodes body if it is non-empty string', () => { 15 | const event = { 16 | body: JSON.stringify({ test: 'yes' }), 17 | } as APIGatewayEvent 18 | 19 | const decoded = decodeBody(event) 20 | 21 | expect(isRight(decoded)).toBe(true) 22 | expect((decoded as any).right).toEqual({ test: 'yes' }) 23 | }) 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/utils/api-gateway/__tests__/response.test.ts: -------------------------------------------------------------------------------- 1 | import { left, right } from 'fp-ts/lib/Either' 2 | import { apiGatewayResponse, toApiGatewayResponse } from '..' 3 | 4 | const corsHeader = { 5 | 'Access-Control-Allow-Origin': '*', 6 | } 7 | 8 | describe('api-gateway', () => { 9 | describe('response.fp', () => { 10 | describe('CASE: success response', () => { 11 | test('returns 200 response zby default', async () => { 12 | const res = await toApiGatewayResponse()(right({ test: 'yes' })) 13 | expect(res.statusCode).toBe(200) 14 | expect(res.body).toBe('{"test":"yes"}') 15 | expect(res.headers).toEqual(corsHeader) 16 | }) 17 | test('returns custom response when custom response handler is provided', async () => { 18 | const handler = (v: any) => 19 | apiGatewayResponse.created({ test: v.test + '!' }) 20 | const res = await toApiGatewayResponse({ 21 | customSuccessResponse: handler, 22 | })(right({ test: 'yes' })) 23 | 24 | expect(res.statusCode).toBe(201) 25 | expect(res.body).toBe('{"test":"yes!"}') 26 | expect(res.headers).toEqual(corsHeader) 27 | }) 28 | }) 29 | 30 | describe('CASE: error response', () => { 31 | test('returns error response by default', async () => { 32 | const res = await toApiGatewayResponse()(left(new Error('not working'))) 33 | 34 | expect(res.statusCode).toBe(500) 35 | expect(res.body).toBe('{"error":"not working"}') 36 | expect(res.headers).toEqual(corsHeader) 37 | }) 38 | test('returns custom error response when custom response handler is provided', async () => { 39 | const customResponse = (e: Error) => 40 | apiGatewayResponse.error({ status: 403, message: e.message + '!' }) 41 | 42 | const error = new Error('error') 43 | const res = await toApiGatewayResponse({ 44 | customErrorResponse: customResponse, 45 | })(left(error)) 46 | 47 | expect(res.body).toBe('{"error":"error!"}') 48 | }) 49 | test('calls onError handler', async () => { 50 | const onError = jest.fn() 51 | 52 | const error = new Error('error') 53 | await toApiGatewayResponse({ onError })(left(error)) 54 | 55 | expect(onError).toHaveBeenCalledWith(error) 56 | }) 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/utils/api-gateway/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parsers' 2 | export * from './response' 3 | -------------------------------------------------------------------------------- /src/utils/api-gateway/parsers.ts: -------------------------------------------------------------------------------- 1 | import { AppError, toAppError } from '@utils/errors/app-error' 2 | import { Maybe } from '@utils/types' 3 | import { APIGatewayEvent } from 'aws-lambda' 4 | import { Either, fromNullable, map } from 'fp-ts/lib/Either' 5 | import { pipe } from 'fp-ts/lib/function' 6 | 7 | export const getPathParams = ( 8 | event: APIGatewayEvent, 9 | ): Either> => 10 | fromNullable(toAppError('path params not found'))(event.pathParameters as any) 11 | 12 | export const getQueryParams = ( 13 | event: APIGatewayEvent, 14 | ): Either>> => 15 | fromNullable(toAppError('query params not found'))(event.queryStringParameters as any) 16 | 17 | export const decodeBody = >( 18 | event: APIGatewayEvent, 19 | ): Either => 20 | pipe(fromNullable(toAppError('body not found'))(event.body), map(JSON.parse)) 21 | -------------------------------------------------------------------------------- /src/utils/api-gateway/response.ts: -------------------------------------------------------------------------------- 1 | import { fold } from 'fp-ts/lib/Either' 2 | 3 | type APIGatewayResponse = { 4 | statusCode: number 5 | body: T 6 | headers?: Record 7 | } 8 | 9 | type Options = { 10 | onError?: (error: Error) => Promise 11 | customErrorResponse?: (error: Error) => APIGatewayResponse 12 | customSuccessResponse?: (res: unknown) => APIGatewayResponse 13 | } 14 | export const toApiGatewayResponse = (options?: Options) => 15 | fold>>( 16 | handlerError(options?.onError)(options?.customErrorResponse), 17 | handleSuccess(options?.customSuccessResponse) 18 | ) 19 | 20 | const handlerError = (onError?: Options['onError']) => ( 21 | customResponse?: (error: Error) => APIGatewayResponse 22 | ) => async (e: Error) => { 23 | if (onError) await onError(e) 24 | if (customResponse) return customResponse(e) 25 | return error(e) 26 | } 27 | 28 | const handleSuccess = ( 29 | customResponse?: (res: unknown) => APIGatewayResponse 30 | ) => async (res: unknown) => { 31 | if (customResponse) return customResponse(res) 32 | return success(res) 33 | } 34 | 35 | type CurrentlySupportedStatusCode = 200 | 201 | CurrentlySupportedErrorCode 36 | type CurrentlySupportedErrorCode = 400 | 403 | 404 | 500 37 | 38 | const formatBody = (statusCode: CurrentlySupportedStatusCode) => ( 39 | body: unknown 40 | ): APIGatewayResponse => ({ 41 | statusCode, 42 | body: JSON.stringify(body, null), 43 | headers: { 44 | 'Access-Control-Allow-Origin': '*', 45 | }, 46 | }) 47 | 48 | const success = formatBody(200) 49 | const created = formatBody(201) 50 | const error = (e: { status?: CurrentlySupportedErrorCode; message: string }) => 51 | formatBody(e.status || 500)({ error: e.message }) 52 | 53 | export const apiGatewayResponse = { 54 | success, 55 | created, 56 | error, 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const IS_OFFLINE = process.env.IS_OFFLINE 2 | export const TABLE_NAME = process.env.tableName || '' 3 | -------------------------------------------------------------------------------- /src/utils/dynamodb/document-client.ts: -------------------------------------------------------------------------------- 1 | import { IS_OFFLINE } from '@utils/constants' 2 | import { DynamoDB } from 'aws-sdk' 3 | 4 | export const dbClient = new DynamoDB.DocumentClient( 5 | IS_OFFLINE 6 | ? { 7 | region: 'localhost', 8 | endpoint: 'http://localhost:8000', 9 | credentials: { 10 | accessKeyId: '_', 11 | secretAccessKey: '_', 12 | }, 13 | } 14 | : {}, 15 | ) 16 | -------------------------------------------------------------------------------- /src/utils/dynamodb/index.ts: -------------------------------------------------------------------------------- 1 | export * from './document-client' 2 | export * from './types' 3 | export * from './utils' 4 | -------------------------------------------------------------------------------- /src/utils/dynamodb/types.ts: -------------------------------------------------------------------------------- 1 | import { RepositoryError } from '@utils/errors/repository-error' 2 | import { ReaderTaskEither, TaskEither } from '@utils/fp' 3 | import { AWSError } from 'aws-sdk' 4 | import { DocumentClient } from 'aws-sdk/clients/dynamodb' 5 | import { PromiseResult } from 'aws-sdk/lib/request' 6 | 7 | export type WithClient = ( 8 | fn: (client: TClient) => TaskEither, 9 | ) => ReaderTaskEither 10 | 11 | export type QueryPayload = Omit 12 | 13 | export type Client< 14 | Key = Record, 15 | CreateInput = Record, 16 | UpdateInput = Record 17 | > = { 18 | get: ( 19 | payload: Omit, 20 | ) => TaskEither> 21 | 22 | create: ( 23 | data: CreateInput, 24 | ) => TaskEither> 25 | 26 | query: ( 27 | payload: QueryPayload, 28 | ) => TaskEither> 29 | 30 | update: ( 31 | key: Key, 32 | data: UpdateInput, 33 | ) => TaskEither> 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/dynamodb/utils.ts: -------------------------------------------------------------------------------- 1 | import { DBPorts } from '@core/ports' 2 | import { RepositoryError, toRepositoryError } from '@utils/errors/repository-error' 3 | import { withEnv, liftPromise, TE, RTE, ReaderTaskEither } from '@utils/fp' 4 | import { pipe, flow } from 'fp-ts/lib/function' 5 | import { Client, WithClient } from './types' 6 | 7 | const getClient: ReaderTaskEither> = withEnv( 8 | ({ db }) => { 9 | const get: Client['get'] = payload => { 10 | const res = db.client 11 | .get({ 12 | TableName: db.tableName, 13 | ...payload, 14 | }) 15 | .promise() 16 | 17 | return liftPromise(res, toRepositoryError) 18 | } 19 | 20 | const create: Client['create'] = data => { 21 | const res = db.client 22 | .put({ 23 | TableName: db.tableName, 24 | Item: data, 25 | }) 26 | .promise() 27 | 28 | return liftPromise(res, toRepositoryError) 29 | } 30 | 31 | const update: Client['update'] = (Key, data) => { 32 | const res = db.client 33 | .update({ 34 | TableName: db.tableName, 35 | Key, 36 | ReturnValues: 'ALL_NEW', 37 | ...data, 38 | }) 39 | .promise() 40 | 41 | return liftPromise(res, toRepositoryError) 42 | } 43 | 44 | const query: Client['query'] = input => { 45 | const res = db.client 46 | .query({ 47 | TableName: db.tableName, 48 | ...input, 49 | }) 50 | .promise() 51 | 52 | return liftPromise(res, toRepositoryError) 53 | } 54 | 55 | return TE.of({ 56 | get, 57 | create, 58 | update, 59 | query, 60 | }) 61 | }, 62 | ) 63 | 64 | export const withClient: WithClient = f => 65 | pipe(getClient, RTE.chain(flow(f, RTE.fromTaskEither)) as any) 66 | 67 | export type DBUpdateInput = { 68 | UpdateExpression: string 69 | ExpressionAttributeNames: Record 70 | 71 | ExpressionAttributeValues: Record 72 | } 73 | 74 | export const mkDBUpdateInput = (input: Record): DBUpdateInput => { 75 | const keys = Object.keys(input) 76 | 77 | const output = keys.reduce( 78 | (exp, key, i) => { 79 | const separator = i ? ',' : '' 80 | return { 81 | UpdateExpression: exp.UpdateExpression + `${separator} #${key} = :${key}`, 82 | ExpressionAttributeNames: { ...exp.ExpressionAttributeNames, [`#${key}`]: key }, 83 | ExpressionAttributeValues: { ...exp.ExpressionAttributeValues, [`:${key}`]: input[key] }, 84 | } 85 | }, 86 | { 87 | UpdateExpression: 'set ', 88 | ExpressionAttributeNames: {}, 89 | ExpressionAttributeValues: {}, 90 | } as DBUpdateInput, 91 | ) 92 | 93 | return output 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/errors/app-error.ts: -------------------------------------------------------------------------------- 1 | export class AppError extends Error { 2 | status = 500 3 | } 4 | 5 | export const toAppError = (e: unknown) => { 6 | if (e instanceof Error) { 7 | return new AppError(e.message) 8 | } 9 | if (typeof e === 'string') { 10 | return new AppError(e) 11 | } 12 | 13 | return new AppError('AppError: unexpected error') 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/errors/input-error.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from './app-error' 2 | 3 | export class InputError extends AppError { 4 | status = 400 5 | } 6 | 7 | export const toInputError = (message: string) => new InputError(message) 8 | -------------------------------------------------------------------------------- /src/utils/errors/not-found-error.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from './app-error' 2 | 3 | export class NotFoundError extends AppError { 4 | status: 404 5 | } 6 | 7 | export const toNotFoundError = (message: string) => new NotFoundError(message) 8 | -------------------------------------------------------------------------------- /src/utils/errors/repository-error.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from './app-error' 2 | 3 | export class RepositoryError extends AppError {} 4 | 5 | export const toRepositoryError = (e: unknown) => { 6 | console.error('RepositoryError:', e) 7 | if (e instanceof Error) { 8 | return new RepositoryError(e.message || 'unexpected error') 9 | } 10 | return new RepositoryError(((e as Error).message as string) || 'unexpected error') 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/fp/__tests__/either.test.ts: -------------------------------------------------------------------------------- 1 | import { toSafeAction } from '../either' 2 | import { isLeft } from 'fp-ts/lib/Either' 3 | 4 | describe('utils', () => { 5 | describe('fp either', () => { 6 | describe('toSafeAction', () => { 7 | test('works with synchronous action', () => { 8 | const error = new Error('error') 9 | const f = (_a: number, _b: string): boolean => { 10 | throw error 11 | } 12 | const safe = toSafeAction(f)(1, '1') 13 | 14 | expect(isLeft(safe)).toBe(true) 15 | expect((safe as any).left).toBe(error) 16 | }) 17 | test('calls custom error handler if provided', async () => { 18 | const f = () => { 19 | throw new Error('error') 20 | } 21 | const handler = (e: Error) => new Error(e.message + '!') 22 | 23 | const safe = toSafeAction(f, handler)() 24 | 25 | expect(isLeft(safe)).toBe(true) 26 | expect((safe as any).left.message).toBe('error!') 27 | }) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/utils/fp/__tests__/functions.test.ts: -------------------------------------------------------------------------------- 1 | import { flip } from '../functions' 2 | 3 | describe('utils', () => { 4 | describe('fp functions', () => { 5 | describe('flip', () => { 6 | test('works correctly', () => { 7 | const f = (a: number) => (b: boolean) => (b ? a : a * -1) 8 | const res = f(10)(true) 9 | const res2 = flip(f)(true)(10) 10 | expect(res).toBe(res2) 11 | }) 12 | }) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/utils/fp/__tests__/reader-task-either.test.ts: -------------------------------------------------------------------------------- 1 | import { isRight, toError, isLeft } from 'fp-ts/lib/Either' 2 | import { run } from 'fp-ts/lib/ReaderTaskEither' 3 | import { withEnvFactory } from '../reader-task-either' 4 | 5 | describe('utils', () => { 6 | describe('fp readerTaskEither', () => { 7 | describe('withEnvFactory', () => { 8 | test('calls function with env', async () => { 9 | const f = jest.fn().mockImplementation(async () => 42) // * mock resolvedValue does not work 10 | 11 | const env = { test: 'yes' } 12 | const action = withEnvFactory(toError)(f) 13 | await run(action, env) 14 | 15 | expect(f).toHaveBeenCalledWith(env) 16 | }) 17 | 18 | test('returns right when functions succeeds', async () => { 19 | const f = jest.fn().mockImplementation(async () => 42) // * mock resolvedValue does not work 20 | 21 | const env = { test: 'yes' } 22 | const action = withEnvFactory(toError)(f) 23 | const res = await run(action, env) 24 | 25 | expect(isRight(res)).toBe(true) 26 | expect((res as any).right).toBe(42) 27 | }) 28 | 29 | test('returns left when functions fails', async () => { 30 | const error = new Error('error') 31 | const f = jest.fn().mockImplementation(async () => { 32 | throw error 33 | }) // * mock resolvedValue does not work 34 | 35 | const env = { test: 'yes' } 36 | const action = withEnvFactory(toError)(f) 37 | const res = await run(action, env) 38 | 39 | expect(isLeft(res)).toBe(true) 40 | expect((res as any).left).toEqual(error) 41 | }) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/utils/fp/__tests__/task-either.test.ts: -------------------------------------------------------------------------------- 1 | import { liftAsyncAction, liftPromise } from '../task-either' 2 | import { isLeft, isRight } from 'fp-ts/lib/Either' 3 | 4 | describe('utils', () => { 5 | describe('fp taskEither', () => { 6 | describe('liftAsync', () => { 7 | test('works with async action', async () => { 8 | const error = new Error('error') 9 | const f = async (_a: number, _b: string): Promise => { 10 | throw error 11 | } 12 | const safe = await liftAsyncAction(f)(1, '1')() 13 | 14 | expect(isLeft(safe)).toBe(true) 15 | expect((safe as any).left).toBe(error) 16 | }) 17 | test('calls custom error handler if provided', async () => { 18 | const f = async (): Promise => { 19 | throw new Error('error') 20 | } 21 | const handler = (e: Error) => new Error(e.message + '!') 22 | 23 | const safe = await liftAsyncAction(f, handler)()() 24 | 25 | expect(isLeft(safe)).toBe(true) 26 | expect((safe as any).left.message).toBe('error!') 27 | }) 28 | }) 29 | 30 | describe('liftPromise', () => { 31 | test('returns taskEither right if promise resolves', async () => { 32 | const res = await liftPromise(Promise.resolve(42))() 33 | 34 | expect(isRight(res)).toBe(true) 35 | expect((res as any).right).toBe(42) 36 | }) 37 | test('returns taskEither left if promise rejects', async () => { 38 | const res = await liftPromise(Promise.reject(-42))() 39 | 40 | expect(isLeft(res)).toBe(true) 41 | expect((res as any).left).toEqual(new Error('-42')) 42 | }) 43 | test('calls custom handler formats if provided', async () => { 44 | const res = await liftPromise(Promise.reject(-42), (e: number) => e * 10)() 45 | 46 | expect(isLeft(res)).toBe(true) 47 | expect((res as any).left).toEqual(-420) 48 | }) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/utils/fp/debug.ts: -------------------------------------------------------------------------------- 1 | export const debug = (process: string) => (data: A) => { 2 | console.log(`${process}: ${JSON.stringify(data, null, 2)}`) 3 | return data 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/fp/decode.ts: -------------------------------------------------------------------------------- 1 | import { flow, pipe } from 'fp-ts/lib/function' 2 | import * as E from 'fp-ts/lib/Either' 3 | import { Decoder, draw } from 'io-ts/Decoder' 4 | import { toInputError } from '@utils/errors/input-error' 5 | 6 | export const decodeWith = (decoder: Decoder) => (data: unknown) => 7 | pipe(decoder.decode(data), E.mapLeft(flow(draw, toInputError))) 8 | -------------------------------------------------------------------------------- /src/utils/fp/either.ts: -------------------------------------------------------------------------------- 1 | import { tryCatch, toError } from 'fp-ts/lib/Either' 2 | 3 | export const toSafeAction = ( 4 | fn: (...args: TArgs) => A, 5 | customToError?: (e: unknown) => E, 6 | ) => (...args: TArgs) => { 7 | return tryCatch( 8 | () => fn(...args), 9 | e => { 10 | if (customToError) return customToError(e) 11 | return toError(e) 12 | }, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/fp/functions.ts: -------------------------------------------------------------------------------- 1 | export const flip = (f: (a: A) => (b: B) => C) => (b: B) => (a: A) => f(a)(b) 2 | -------------------------------------------------------------------------------- /src/utils/fp/index.ts: -------------------------------------------------------------------------------- 1 | export * from './either' 2 | export * from './task-either' 3 | export * from './reader-task-either' 4 | export * from './functions' 5 | export * from './decode' 6 | export * from './misc' 7 | export * from './debug' 8 | 9 | export * as E from 'fp-ts/lib/Either' 10 | export { Either } from 'fp-ts/lib/Either' 11 | 12 | export * as TE from 'fp-ts/lib/TaskEither' 13 | export { TaskEither } from 'fp-ts/lib/TaskEither' 14 | 15 | export * as O from 'fp-ts/lib/Option' 16 | export { Option } from 'fp-ts/lib/Option' 17 | 18 | export * as A from 'fp-ts/lib/Array' 19 | 20 | export * as RTE from 'fp-ts/lib/ReaderTaskEither' 21 | export { ReaderTaskEither } from 'fp-ts/lib/ReaderTaskEither' 22 | 23 | export * as NA from 'fp-ts/lib/NonEmptyArray' 24 | export { NonEmptyArray } from 'fp-ts/lib/NonEmptyArray' 25 | 26 | export * as IE from 'fp-ts/lib/IOEither' 27 | export { IOEither } from 'fp-ts/lib/IOEither' 28 | -------------------------------------------------------------------------------- /src/utils/fp/misc.ts: -------------------------------------------------------------------------------- 1 | export const singleton = (k: K) => (a: V) => ({ [k]: a } as Record) 2 | 3 | export const get = (key: K) => { 4 | function getter(data: T): T[typeof key] 5 | function getter(data: T) { 6 | return data[key] 7 | } 8 | 9 | return getter 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/fp/reader-task-either.ts: -------------------------------------------------------------------------------- 1 | import { AppError, toAppError } from '@utils/errors/app-error' 2 | import { constant, flow, pipe } from 'fp-ts/lib/function' 3 | import { TaskEither, ReaderTaskEither, RTE } from '.' 4 | import { flip } from './functions' 5 | import { liftPromise } from './task-either' 6 | 7 | export const withEnvFactory = (toError: (e: unknown) => E) => < 8 | R, 9 | E extends AppError, 10 | A 11 | >( 12 | f: (env: R) => TaskEither | Promise, 13 | ): ReaderTaskEither => { 14 | return pipe( 15 | RTE.ask(), 16 | RTE.chain(env => { 17 | const res = f(env) 18 | 19 | if (_isPromise(res)) { 20 | return RTE.fromTaskEither(liftPromise(res, toError)) 21 | } 22 | 23 | return RTE.fromTaskEither(res) 24 | }), 25 | ) 26 | } 27 | 28 | export const withEnv = withEnvFactory(toAppError) 29 | 30 | export const withEnvL = flow(withEnv, constant) 31 | 32 | type WithEnvF = ( 33 | f: (r: R) => (a: A) => TaskEither, 34 | ) => (a: A) => ReaderTaskEither 35 | 36 | export const withEnvF: WithEnvF = f => flow(flip(f), withEnv) 37 | 38 | export const constInput = (ma: ReaderTaskEither) => (data: B) => { 39 | return pipe(ma, RTE.map(constant(data))) 40 | } 41 | 42 | const _isPromise = (obj: any): obj is Promise => obj instanceof Promise 43 | -------------------------------------------------------------------------------- /src/utils/fp/task-either.ts: -------------------------------------------------------------------------------- 1 | import { toAppError } from '@utils/errors/app-error' 2 | import * as TE from 'fp-ts/lib/TaskEither' 3 | 4 | export const liftPromise = (p: Promise, customToError?: (e: unknown) => E) => 5 | liftAsyncAction(() => p, customToError)() 6 | 7 | export const liftAsyncAction = ( 8 | f: (...args: TArgs) => Promise, 9 | customToError?: (e: unknown) => E, 10 | ) => { 11 | return TE.tryCatchK(f, e => { 12 | if (customToError) return customToError(e) 13 | return toAppError(e) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Empty = null | undefined 2 | export type Maybe = A | Empty 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "@core": ["core"], 6 | "@core/*": ["core/*"], 7 | "@utils/*": ["utils/*"], 8 | "@resources": ["driven-adapters"], 9 | "@resources/*": ["driven-adapters/*"] 10 | }, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "noImplicitAny": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": true, 17 | "skipLibCheck": true, 18 | "strictNullChecks": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true, 21 | "target": "ES2019", 22 | "outDir": ".build", 23 | "rootDir": "./" 24 | }, 25 | "include": ["src/"], 26 | "exclude": ["node_modules/"] 27 | } 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require('path') 3 | const slsw = require('serverless-webpack') 4 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 5 | const nodeExternals = require('webpack-node-externals') 6 | 7 | module.exports = { 8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 9 | entry: slsw.lib.entries, 10 | resolve: { 11 | extensions: ['.json', '.ts'], 12 | alias: { 13 | '@core': path.resolve(__dirname, './src/core'), 14 | '@utils': path.resolve(__dirname, './src/utils'), 15 | '@resources': path.resolve(__dirname, './src/driven-adapters'), 16 | }, 17 | }, 18 | output: { 19 | libraryTarget: 'commonjs', 20 | path: path.join(__dirname, '.webpack'), 21 | filename: '[name].js', 22 | }, 23 | externals: [nodeExternals()], 24 | target: 'node', 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.ts$/, 29 | loader: 'ts-loader', 30 | options: { transpileOnly: true, configFile: 'tsconfig.json' }, 31 | }, 32 | ], 33 | }, 34 | plugins: [ 35 | new ForkTsCheckerWebpackPlugin({ 36 | async: true, 37 | eslint: { 38 | enabled: true, 39 | files: './src/**/*.{ts,tsx}', 40 | }, 41 | }), 42 | ], 43 | } 44 | --------------------------------------------------------------------------------