├── .nvmrc
├── src
├── core
│ ├── index.ts
│ ├── use-cases
│ │ ├── index.ts
│ │ ├── surveys
│ │ │ ├── get-survey-by-id.ts
│ │ │ ├── create-survey.ts
│ │ │ └── update-survey.ts
│ │ └── responses
│ │ │ ├── get-by-form-id.ts
│ │ │ └── create-response.ts
│ ├── response.ts
│ ├── ports.ts
│ └── survey.ts
├── driven-adapters
│ ├── index.ts
│ ├── response-repository
│ │ ├── types.ts
│ │ ├── response-transformation.ts
│ │ └── response-repository.ts
│ └── survey-repository
│ │ ├── types.ts
│ │ ├── survey-transformation.ts
│ │ └── survey-repository.ts
├── utils
│ ├── api-gateway
│ │ ├── index.ts
│ │ ├── __tests__
│ │ │ ├── parsers.test.ts
│ │ │ └── response.test.ts
│ │ ├── parsers.ts
│ │ └── response.ts
│ ├── types.ts
│ ├── fp
│ │ ├── functions.ts
│ │ ├── debug.ts
│ │ ├── decode.ts
│ │ ├── misc.ts
│ │ ├── either.ts
│ │ ├── __tests__
│ │ │ ├── functions.test.ts
│ │ │ ├── either.test.ts
│ │ │ ├── reader-task-either.test.ts
│ │ │ └── task-either.test.ts
│ │ ├── task-either.ts
│ │ ├── index.ts
│ │ └── reader-task-either.ts
│ ├── dynamodb
│ │ ├── index.ts
│ │ ├── document-client.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── constants
│ │ └── index.ts
│ └── errors
│ │ ├── input-error.ts
│ │ ├── not-found-error.ts
│ │ ├── app-error.ts
│ │ └── repository-error.ts
└── primary-adapters
│ ├── survey
│ ├── get-survey-by-id.ts
│ ├── create-survey.ts
│ └── update-survey.ts
│ └── response
│ ├── get-by-form-id.ts
│ └── create-response.ts
├── .seeds
├── surveys.json
└── responses.json
├── .prettierrc.json
├── jest.config.json
├── resources
├── resources.yml
└── functions.yml
├── tsconfig.json
├── sample-request.http
├── .eslintrc.json
├── webpack.config.js
├── serverless.yml
├── README.md
├── package.json
└── .gitignore
/.nvmrc:
--------------------------------------------------------------------------------
1 | 12
--------------------------------------------------------------------------------
/src/core/index.ts:
--------------------------------------------------------------------------------
1 | export * from './survey'
2 | export * from './response'
3 |
--------------------------------------------------------------------------------
/src/driven-adapters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './survey-repository/survey-repository'
2 |
--------------------------------------------------------------------------------
/src/utils/api-gateway/index.ts:
--------------------------------------------------------------------------------
1 | export * from './parsers'
2 | export * from './response'
3 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | export type Empty = null | undefined
2 | export type Maybe = A | Empty
3 |
--------------------------------------------------------------------------------
/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/dynamodb/index.ts:
--------------------------------------------------------------------------------
1 | export * from './document-client'
2 | export * from './types'
3 | export * from './utils'
4 |
--------------------------------------------------------------------------------
/src/utils/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const IS_OFFLINE = process.env.IS_OFFLINE
2 | export const TABLE_NAME = process.env.tableName || ''
3 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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__/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------