├── .gitignore ├── README.md ├── docs ├── DOCS.md ├── documentation │ ├── .nojekyll │ ├── assets │ │ ├── highlight.css │ │ ├── main.js │ │ ├── search.js │ │ └── style.css │ ├── classes │ │ ├── errors.MaxNumberOfPlaylistsError.html │ │ ├── errors.MaxPlaylistSizeError.html │ │ ├── errors.PaymentInvalidError.html │ │ ├── errors.ResourceNotFoundError.html │ │ ├── errors.SubscriptionAlreadyUpgradedError.html │ │ └── errors.ValidationError.html │ ├── enums │ │ ├── dto_customer_account_customer_account.PaymentStatus.html │ │ └── dto_customer_account_customer_account.SubscriptionType.html │ ├── functions │ │ ├── use_cases_create_customer_account_create_customer_account.createCustomerAccountUseCase.html │ │ ├── use_cases_create_customer_playlist_create_customer_playlist.createCustomerPlaylistUseCase.html │ │ ├── use_cases_retrieve_customer_account_retrieve_customer_account.retrieveCustomerAccountUseCase.html │ │ └── use_cases_upgrade_customer_account_upgrade_customer_account.upgradeCustomerAccountUseCase.html │ ├── index.html │ ├── media │ │ ├── diagram.png │ │ ├── header-part-one.png │ │ └── header.png │ ├── modules.html │ ├── modules │ │ ├── dto_customer_account_customer_account.html │ │ ├── dto_customer_address_customer_address.html │ │ ├── dto_customer_playlist_customer_playlist.html │ │ ├── errors.html │ │ ├── events_customer_account_created.html │ │ ├── events_customer_account_upgraded.html │ │ ├── events_customer_playlist_created.html │ │ ├── use_cases_create_customer_account_create_customer_account.html │ │ ├── use_cases_create_customer_playlist_create_customer_playlist.html │ │ ├── use_cases_retrieve_customer_account_retrieve_customer_account.html │ │ └── use_cases_upgrade_customer_account_upgrade_customer_account.html │ ├── types │ │ ├── dto_customer_account_customer_account.CreateCustomerAccountDto.html │ │ ├── dto_customer_account_customer_account.CustomerAccountDto.html │ │ ├── dto_customer_account_customer_account.NewCustomerAccountDto.html │ │ ├── dto_customer_address_customer_address.CustomerAddressDto.html │ │ ├── dto_customer_playlist_customer_playlist.CustomerPlaylistDto.html │ │ ├── dto_customer_playlist_customer_playlist.NewCustomerPlaylistDto.html │ │ └── dto_customer_playlist_customer_playlist.NewCustomerPlaylistSongDto.html │ └── variables │ │ ├── events_customer_account_created.eventName.html │ │ ├── events_customer_account_created.eventSchema.html │ │ ├── events_customer_account_created.eventSource.html │ │ ├── events_customer_account_created.eventVersion.html │ │ ├── events_customer_account_upgraded.eventName.html │ │ ├── events_customer_account_upgraded.eventSchema.html │ │ ├── events_customer_account_upgraded.eventSource.html │ │ ├── events_customer_account_upgraded.eventVersion.html │ │ ├── events_customer_playlist_created.eventName.html │ │ ├── events_customer_playlist_created.eventSchema.html │ │ ├── events_customer_playlist_created.eventSource.html │ │ └── events_customer_playlist_created.eventVersion.html └── images │ ├── diagram.png │ └── header.png ├── onion-sounds ├── .gitignore ├── .npmignore ├── .nvmrc ├── __mocks__ │ ├── @aws-lambda-powertools │ │ └── logger.ts │ ├── aws-sdk.ts │ └── uuid.ts ├── bin │ └── onion-sounds.ts ├── cdk.json ├── jest.config.js ├── package-lock.json ├── package.json ├── packages │ ├── apigw-error-handler │ │ ├── error-handler.test.ts │ │ ├── error-handler.ts │ │ └── index.ts │ ├── logger │ │ ├── index.ts │ │ └── logger.ts │ └── schema-validator │ │ ├── index.ts │ │ ├── schema-validator.test.ts │ │ └── schema-validator.ts ├── stateful │ └── stateful.ts ├── stateless │ ├── src │ │ ├── adapters │ │ │ ├── primary │ │ │ │ ├── add-song-to-playlist │ │ │ │ │ ├── add-song-to-playlist.adapter.test.ts │ │ │ │ │ ├── add-song-to-playlist.adapter.ts │ │ │ │ │ ├── add-song-to-playlist.schema.test.ts │ │ │ │ │ └── add-song-to-playlist.schema.ts │ │ │ │ ├── create-customer-account │ │ │ │ │ ├── create-customer-account.adapter.test.ts │ │ │ │ │ ├── create-customer-account.adapter.ts │ │ │ │ │ ├── create-customer-account.schema.test.ts │ │ │ │ │ └── create-customer-account.schema.ts │ │ │ │ ├── create-customer-playlist │ │ │ │ │ ├── create-customer-playlist.adpater.test.ts │ │ │ │ │ ├── create-customer-playlist.adpater.ts │ │ │ │ │ ├── create-customer-playlist.schema.test.ts │ │ │ │ │ └── create-customer-playlist.schema.ts │ │ │ │ ├── retrieve-customer-account │ │ │ │ │ ├── retrieve-customer-account.adapter.test.ts │ │ │ │ │ └── retrieve-customer-account.adapter.ts │ │ │ │ └── upgrade-customer-account │ │ │ │ │ ├── upgrade-customer-account.adapter.test.ts │ │ │ │ │ └── upgrade-customer-account.adapter.ts │ │ │ └── secondary │ │ │ │ ├── database-adapter │ │ │ │ ├── database-adapter.test.ts │ │ │ │ ├── database-adapter.ts │ │ │ │ └── index.ts │ │ │ │ └── event-adapter │ │ │ │ ├── event-adapter.test.ts │ │ │ │ ├── event-adapter.ts │ │ │ │ └── index.ts │ │ ├── config │ │ │ ├── config.test.ts │ │ │ ├── config.ts │ │ │ └── index.ts │ │ ├── dto │ │ │ ├── customer-account │ │ │ │ ├── customer-account.ts │ │ │ │ └── index.ts │ │ │ ├── customer-address │ │ │ │ ├── customer-address.ts │ │ │ │ └── index.ts │ │ │ └── customer-playlist │ │ │ │ ├── customer-playlist.ts │ │ │ │ └── index.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ ├── max-number-of-playlists-error.ts │ │ │ ├── max-playlist-size-error.ts │ │ │ ├── payment-invalid-error.ts │ │ │ ├── playlist-not-found-error.ts │ │ │ ├── resource-not-found.ts │ │ │ ├── subscription-already-upgraded-error.ts │ │ │ └── validation-error.ts │ │ ├── events │ │ │ ├── customer-account-created.ts │ │ │ ├── customer-account-updated.ts │ │ │ ├── customer-account-upgraded.ts │ │ │ ├── customer-playlist-created.ts │ │ │ └── song-added-to-playlist.ts │ │ ├── schemas │ │ │ ├── customer-account.schema.test.ts │ │ │ ├── customer-account.schema.ts │ │ │ ├── customer-address.schema.test.ts │ │ │ ├── customer-address.schema.ts │ │ │ ├── customer-playlist.schema.test.ts │ │ │ └── customer-playlist.schema.ts │ │ ├── shared │ │ │ ├── date-utils.ts │ │ │ └── index.ts │ │ └── use-cases │ │ │ ├── add-song-to-playlist │ │ │ ├── add-song-to-playlist.test.ts │ │ │ ├── add-song-to-playlist.ts │ │ │ └── index.ts │ │ │ ├── create-customer-account │ │ │ ├── create-customer-account.test.ts │ │ │ ├── create-customer-account.ts │ │ │ └── index.ts │ │ │ ├── create-customer-playlist │ │ │ ├── create-customer-playlist.test.ts │ │ │ ├── create-customer-playlist.ts │ │ │ └── index.ts │ │ │ ├── retrieve-customer-account │ │ │ ├── index.ts │ │ │ ├── retrieve-customer-account.test.ts │ │ │ └── retrieve-customer-account.ts │ │ │ └── upgrade-customer-account │ │ │ ├── index.ts │ │ │ ├── upgrade-customer-account.test.ts │ │ │ └── upgrade-customer-account.ts │ └── stateless.ts ├── tsconfig.json └── typedoc.json ├── open-api └── customer-accounts-open-api-v1.yml └── postman └── Serverless Clean Architecture.postman_collection.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | onion-sounds/coverage/ 3 | onion-sounds/cdk-outputs.json 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless Lightweight Clean Code Approach 2 | 3 | An opinionated example of a lightweight 'clean code' Lambda function architecture, with code examples written in the AWS CDK and TypeScript. 4 | 5 | ![image](./docs/images/header.png) 6 | 7 | The article can be found here: https://medium.com/@leejamesgilmore/serverless-lightweight-clean-code-approach-84133c90eeeb 8 | 9 | ## Getting started 10 | 11 | To deploy the solution please look at the steps in the article linked above. 12 | 13 | ** The information and code provided are my own and I accept no responsibility on the use of the information. ** 14 | -------------------------------------------------------------------------------- /docs/DOCS.md: -------------------------------------------------------------------------------- 1 | # Onion Sounds - Customer Account Domain 2 | 3 | ## Introduction 4 | 5 | This documentation details the use cases involved in the customer account domain 6 | 7 | ## Diagram 8 | 9 | ![architecture](media://diagram.png) 10 | -------------------------------------------------------------------------------- /docs/documentation/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/documentation/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-code-background: #FFFFFF; 3 | --dark-code-background: #1E1E1E; 4 | } 5 | 6 | @media (prefers-color-scheme: light) { :root { 7 | --code-background: var(--light-code-background); 8 | } } 9 | 10 | @media (prefers-color-scheme: dark) { :root { 11 | --code-background: var(--dark-code-background); 12 | } } 13 | 14 | :root[data-theme='light'] { 15 | --code-background: var(--light-code-background); 16 | } 17 | 18 | :root[data-theme='dark'] { 19 | --code-background: var(--dark-code-background); 20 | } 21 | 22 | pre, code { background: var(--code-background); } 23 | -------------------------------------------------------------------------------- /docs/documentation/index.html: -------------------------------------------------------------------------------- 1 | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 |

Onion Sounds - Customer Account Domain Service - v0.1.0

15 |
16 | 17 |

Onion Sounds - Customer Account Domain

18 |
19 | 20 | 21 |

Introduction

22 |
23 |

This documentation details the use cases involved in the customer account domain

24 | 25 | 26 |

Diagram

27 |
28 |

architecture

29 |
30 |
60 |
-------------------------------------------------------------------------------- /docs/documentation/media/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/serverless-clean-code-experience/4d0b47b2b67d120f9caba84121cfb03cdc478adf/docs/documentation/media/diagram.png -------------------------------------------------------------------------------- /docs/documentation/media/header-part-one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/serverless-clean-code-experience/4d0b47b2b67d120f9caba84121cfb03cdc478adf/docs/documentation/media/header-part-one.png -------------------------------------------------------------------------------- /docs/documentation/media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/serverless-clean-code-experience/4d0b47b2b67d120f9caba84121cfb03cdc478adf/docs/documentation/media/header.png -------------------------------------------------------------------------------- /docs/documentation/modules/dto_customer_address_customer_address.html: -------------------------------------------------------------------------------- 1 | dto/customer-address/customer-address | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Module dto/customer-address/customer-address

18 |
19 |
20 |
21 |
22 |

Index

23 |
24 |

Type Aliases

25 |
27 |
60 |
-------------------------------------------------------------------------------- /docs/documentation/modules/use_cases_create_customer_account_create_customer_account.html: -------------------------------------------------------------------------------- 1 | use-cases/create-customer-account/create-customer-account | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Module use-cases/create-customer-account/create-customer-account

18 |
19 |
20 |
21 |
22 |

Index

23 |
24 |

Functions

25 |
27 |
60 |
-------------------------------------------------------------------------------- /docs/documentation/modules/use_cases_create_customer_playlist_create_customer_playlist.html: -------------------------------------------------------------------------------- 1 | use-cases/create-customer-playlist/create-customer-playlist | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Module use-cases/create-customer-playlist/create-customer-playlist

18 |
19 |
20 |
21 |
22 |

Index

23 |
24 |

Functions

25 |
27 |
60 |
-------------------------------------------------------------------------------- /docs/documentation/modules/use_cases_retrieve_customer_account_retrieve_customer_account.html: -------------------------------------------------------------------------------- 1 | use-cases/retrieve-customer-account/retrieve-customer-account | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Module use-cases/retrieve-customer-account/retrieve-customer-account

18 |
19 |
20 |
21 |
22 |

Index

23 |
24 |

Functions

25 |
27 |
60 |
-------------------------------------------------------------------------------- /docs/documentation/modules/use_cases_upgrade_customer_account_upgrade_customer_account.html: -------------------------------------------------------------------------------- 1 | use-cases/upgrade-customer-account/upgrade-customer-account | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Module use-cases/upgrade-customer-account/upgrade-customer-account

18 |
19 |
20 |
21 |
22 |

Index

23 |
24 |

Functions

25 |
27 |
60 |
-------------------------------------------------------------------------------- /docs/documentation/variables/events_customer_account_created.eventVersion.html: -------------------------------------------------------------------------------- 1 | eventVersion | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 | 19 |
eventVersion: "1" = '1'
20 |
56 |
-------------------------------------------------------------------------------- /docs/images/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/serverless-clean-code-experience/4d0b47b2b67d120f9caba84121cfb03cdc478adf/docs/images/diagram.png -------------------------------------------------------------------------------- /docs/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/serverless-clean-code-experience/4d0b47b2b67d120f9caba84121cfb03cdc478adf/docs/images/header.png -------------------------------------------------------------------------------- /onion-sounds/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /onion-sounds/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /onion-sounds/.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.2 2 | -------------------------------------------------------------------------------- /onion-sounds/__mocks__/@aws-lambda-powertools/logger.ts: -------------------------------------------------------------------------------- 1 | export const Logger = jest.fn().mockImplementation(() => { 2 | return { 3 | info: jest.fn(), 4 | debug: jest.fn(), 5 | error: jest.fn(), 6 | }; 7 | }); 8 | 9 | export const injectLambdaContext = jest.fn().mockImplementation(() => { 10 | return { before: jest.fn(), after: jest.fn(), onError: jest.fn() }; 11 | }); 12 | -------------------------------------------------------------------------------- /onion-sounds/__mocks__/aws-sdk.ts: -------------------------------------------------------------------------------- 1 | // we can import this mock response and change it now per test 2 | export const awsSdkGetPromiseResponse = jest 3 | .fn() 4 | .mockReturnValue(Promise.resolve({ Item: {} })); 5 | 6 | // we can import this mock response and change it now per test 7 | export const awsSdkPutPromiseResponse = jest.fn().mockReturnValue( 8 | Promise.resolve({ 9 | ConsumedCapacity: 1, 10 | Attributes: {}, 11 | ItemCollectionMetrics: {}, 12 | }) 13 | ); 14 | 15 | // we can import this mock response and change it now per test 16 | export const awsSdkPutEventPromiseResponse = jest.fn().mockReturnValue( 17 | Promise.resolve({ 18 | Entries: [], 19 | }) 20 | ); 21 | 22 | const getFn = jest 23 | .fn() 24 | .mockImplementation(() => ({ promise: awsSdkGetPromiseResponse })); 25 | 26 | const putFn = jest 27 | .fn() 28 | .mockImplementation(() => ({ promise: awsSdkPutPromiseResponse })); 29 | 30 | const putEventsFn = jest 31 | .fn() 32 | .mockImplementation(() => ({ promise: awsSdkPutEventPromiseResponse })); 33 | 34 | class DocumentClient { 35 | get = getFn; 36 | put = putFn; 37 | } 38 | 39 | export const DynamoDB = { 40 | DocumentClient, 41 | }; 42 | 43 | export class EventBridge { 44 | putEvents = putEventsFn; 45 | } 46 | -------------------------------------------------------------------------------- /onion-sounds/__mocks__/uuid.ts: -------------------------------------------------------------------------------- 1 | export const v4 = jest 2 | .fn() 3 | .mockReturnValue('f39e49ad-8f88-448f-8a15-41d560ad6d70'); 4 | -------------------------------------------------------------------------------- /onion-sounds/bin/onion-sounds.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import 'source-map-support/register'; 4 | 5 | import * as cdk from 'aws-cdk-lib'; 6 | 7 | import { OnionSoundsStatefulStack } from '../stateful/stateful'; 8 | import { OnionSoundsStatelessStack } from '../stateless/stateless'; 9 | 10 | const app = new cdk.App(); 11 | const statefulStack = new OnionSoundsStatefulStack( 12 | app, 13 | 'OnionSoundsStatefulStack', 14 | {} 15 | ); 16 | new OnionSoundsStatelessStack(app, 'OnionSoundsStatelessStack', { 17 | accountsTable: statefulStack.accountsTable, 18 | accountsEventBus: statefulStack.accountsEventBus, 19 | }); 20 | -------------------------------------------------------------------------------- /onion-sounds/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/onion-sounds.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 31 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 32 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 33 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 34 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 35 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 36 | "@aws-cdk/core:enablePartitionLiterals": true, 37 | "@aws-cdk/core:target-partitions": [ 38 | "aws", 39 | "aws-cn" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /onion-sounds/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: [''], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | }, 8 | moduleNameMapper: { 9 | '^@adapters/(.*)': '/stateless/src/adapters/$1', 10 | '^@config/(.*)': '/stateless/src/config/$1', 11 | '^@domain/(.*)': '/stateless/src/domain/$1', 12 | '^@entity/(.*)': '/stateless/src/entity/$1', 13 | '^@schemas/(.*)': '/stateless/src/schemas/$1', 14 | '^@shared/(.*)': '/stateless/src/shared/$1', 15 | '^@errors/(.*)': '/stateless/src/errors/$1', 16 | '^@events/(.*)': '/stateless/src/events/$1', 17 | '^@models/(.*)': '/stateless/src/models/$1', 18 | '^@dto/(.*)': '/stateless/src/dto/$1', 19 | '^@use-cases/(.*)': '/stateless/src/use-cases/$1', 20 | '^@packages/(.*)': '/packages/$1', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /onion-sounds/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onion-sounds", 3 | "version": "0.1.0", 4 | "bin": { 5 | "onion-sounds": "bin/onion-sounds.js" 6 | }, 7 | "scripts": { 8 | "predocs": "npx cdk-dia && mv diagram.png ../docs/images/diagram.png && rm diagram.dot", 9 | "docs": "npx typedoc", 10 | "test": "jest --maxWorkers=50% --noStackTrace --silent", 11 | "test:coverage": "jest --maxWorkers=50% --coverage --silent", 12 | "test:watch": "jest --watch --maxWorkers=25% --noStackTrace --silent", 13 | "ls": "cdk ls", 14 | "cdk": "cdk", 15 | "diff": "cdk diff", 16 | "clear:jest": "jest --clearCache", 17 | "deploy": "cdk deploy --outputs-file ./cdk-outputs.json --all", 18 | "deploy:hot": "cdk deploy --outputs-file ./cdk-outputs.json --all --hotswap", 19 | "remove": "cdk destroy --all", 20 | "deploy:stateful": "cdk deploy StatefulStack --outputs-file ./cdk-outputs.json", 21 | "remove:stateful": "cdk destroy StatefulStack", 22 | "deploy:stateless": "cdk deploy StatelessStack --outputs-file ./cdk-outputs.json", 23 | "remove:stateless": "cdk destroy StatelessStack" 24 | }, 25 | "devDependencies": { 26 | "@types/aws-lambda": "^8.10.108", 27 | "@types/convict": "^6.1.1", 28 | "@types/jest": "^27.5.2", 29 | "@types/node": "10.17.27", 30 | "@types/prettier": "2.6.0", 31 | "@types/uuid": "^8.3.4", 32 | "aws-cdk": "2.47.0", 33 | "cdk-dia": "^0.7.0", 34 | "esbuild": "^0.15.12", 35 | "jest": "^27.5.1", 36 | "ts-jest": "^27.1.4", 37 | "ts-node": "^10.9.1", 38 | "typescript": "~3.9.7" 39 | }, 40 | "dependencies": { 41 | "@aws-lambda-powertools/logger": "^1.4.1", 42 | "@aws-lambda-powertools/metrics": "^1.4.1", 43 | "@aws-lambda-powertools/tracer": "^1.4.1", 44 | "@middy/core": "^3.6.2", 45 | "ajv": "^8.11.0", 46 | "ajv-formats": "^2.1.1", 47 | "aws-cdk-lib": "2.47.0", 48 | "aws-sdk": "^2.1242.0", 49 | "constructs": "^10.0.0", 50 | "convict": "^6.2.3", 51 | "source-map-support": "^0.5.21", 52 | "uuid": "^9.0.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /onion-sounds/packages/apigw-error-handler/error-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { MaxNumberOfPlaylistsError } from '@errors/max-number-of-playlists-error'; 2 | import { MaxPlaylistSizeError } from '@errors/max-playlist-size-error'; 3 | import { PaymentInvalidError } from '@errors/payment-invalid-error'; 4 | import { PlaylistNotFoundError } from '@errors/playlist-not-found-error'; 5 | import { ResourceNotFoundError } from '@errors/resource-not-found'; 6 | import { SubscriptionAlreadyUpgradedError } from '@errors/subscription-already-upgraded-error'; 7 | import { ValidationError } from '@errors/validation-error'; 8 | import { errorHandler } from './error-handler'; 9 | 10 | describe('error-handler', () => { 11 | it('should default the error and status code on unknown instance type', () => { 12 | // arrange 13 | const error = null; 14 | 15 | // act / assert 16 | expect(errorHandler(error)).toMatchInlineSnapshot(` 17 | Object { 18 | "body": "\\"An error has occurred\\"", 19 | "statusCode": 500, 20 | } 21 | `); 22 | }); 23 | 24 | it('should default the error and status code on unknown error', () => { 25 | // arrange 26 | const error = new Error('unknown error'); 27 | 28 | // act / assert 29 | expect(errorHandler(error)).toMatchInlineSnapshot(` 30 | Object { 31 | "body": "\\"An error has occurred\\"", 32 | "statusCode": 500, 33 | } 34 | `); 35 | }); 36 | 37 | it('should return the correct response on ValidationError', () => { 38 | // arrange 39 | const error = new ValidationError('this is a validation error'); 40 | 41 | // act / assert 42 | expect(errorHandler(error)).toMatchInlineSnapshot(` 43 | Object { 44 | "body": "\\"this is a validation error\\"", 45 | "statusCode": 400, 46 | } 47 | `); 48 | }); 49 | 50 | it('should return the correct response on PaymentInvalidError', () => { 51 | // arrange 52 | const error = new PaymentInvalidError('this is a payment invalid error'); 53 | 54 | // act / assert 55 | expect(errorHandler(error)).toMatchInlineSnapshot(` 56 | Object { 57 | "body": "\\"this is a payment invalid error\\"", 58 | "statusCode": 400, 59 | } 60 | `); 61 | }); 62 | 63 | it('should return the correct response on PaymentInvalidError', () => { 64 | // arrange 65 | const error = new MaxNumberOfPlaylistsError('max number of playlists'); 66 | 67 | // act / assert 68 | expect(errorHandler(error)).toMatchInlineSnapshot(` 69 | Object { 70 | "body": "\\"max number of playlists\\"", 71 | "statusCode": 400, 72 | } 73 | `); 74 | }); 75 | 76 | it('should return the correct response on PaymentInvalidError', () => { 77 | // arrange 78 | const error = new MaxPlaylistSizeError('max playlist size'); 79 | 80 | // act / assert 81 | expect(errorHandler(error)).toMatchInlineSnapshot(` 82 | Object { 83 | "body": "\\"max playlist size\\"", 84 | "statusCode": 400, 85 | } 86 | `); 87 | }); 88 | 89 | it('should return the correct response on PlaylistNotFoundError', () => { 90 | // arrange 91 | const error = new PlaylistNotFoundError('playlist not found'); 92 | 93 | // act / assert 94 | expect(errorHandler(error)).toMatchInlineSnapshot(` 95 | Object { 96 | "body": "\\"playlist not found\\"", 97 | "statusCode": 400, 98 | } 99 | `); 100 | }); 101 | 102 | it('should return the correct response on SubscriptionAlreadyUpgradedError', () => { 103 | // arrange 104 | const error = new SubscriptionAlreadyUpgradedError( 105 | 'account already upgraded' 106 | ); 107 | 108 | // act / assert 109 | expect(errorHandler(error)).toMatchInlineSnapshot(` 110 | Object { 111 | "body": "\\"account already upgraded\\"", 112 | "statusCode": 400, 113 | } 114 | `); 115 | }); 116 | 117 | it('should return the correct response on ResourceNotFoundError', () => { 118 | // arrange 119 | const error = new ResourceNotFoundError('account with id 444 not found'); 120 | 121 | // act / assert 122 | expect(errorHandler(error)).toMatchInlineSnapshot(` 123 | Object { 124 | "body": "\\"account with id 444 not found\\"", 125 | "statusCode": 404, 126 | } 127 | `); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /onion-sounds/packages/apigw-error-handler/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResult } from 'aws-lambda'; 2 | import { logger } from '@packages/logger'; 3 | 4 | // we would typically use middy - but to keep this simple to read 5 | // without mutliple additional packages lets build outselves 6 | export function errorHandler(error: Error | unknown): APIGatewayProxyResult { 7 | console.error(error); 8 | 9 | let errorMessage: string; 10 | let statusCode: number; 11 | 12 | if (error instanceof Error) { 13 | switch (error.name) { 14 | case 'PaymentInvalidError': 15 | case 'SubscriptionAlreadyUpgradedError': 16 | case 'ValidationError': 17 | case 'MaxNumberOfPlaylistsError': 18 | case 'MaxPlaylistSizeError': 19 | case 'PlaylistNotFoundError': 20 | errorMessage = error.message; 21 | statusCode = 400; 22 | break; 23 | case 'ResourceNotFound': 24 | errorMessage = error.message; 25 | statusCode = 404; 26 | break; 27 | default: 28 | errorMessage = 'An error has occurred'; 29 | statusCode = 500; 30 | break; 31 | } 32 | } else { 33 | errorMessage = 'An error has occurred'; 34 | statusCode = 500; 35 | } 36 | 37 | logger.error(errorMessage); 38 | 39 | return { 40 | statusCode: statusCode, 41 | body: JSON.stringify(errorMessage), 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /onion-sounds/packages/apigw-error-handler/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-handler'; 2 | -------------------------------------------------------------------------------- /onion-sounds/packages/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | -------------------------------------------------------------------------------- /onion-sounds/packages/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@aws-lambda-powertools/logger'; 2 | 3 | export const logger = new Logger(); 4 | -------------------------------------------------------------------------------- /onion-sounds/packages/schema-validator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema-validator'; 2 | -------------------------------------------------------------------------------- /onion-sounds/packages/schema-validator/schema-validator.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaValidator } from './schema-validator'; 2 | 3 | let body = { 4 | firstName: 'Lee', 5 | surname: 'Gilmore', 6 | }; 7 | 8 | let schema = { 9 | type: 'object', 10 | required: ['firstName', 'surname'], 11 | maxProperties: 2, 12 | minProperties: 2, 13 | properties: { 14 | firstName: { 15 | type: 'string', 16 | pattern: '^[a-zA-Z]+$', 17 | }, 18 | surname: { 19 | type: 'string', 20 | pattern: '^[a-zA-Z]+$', 21 | }, 22 | }, 23 | }; 24 | 25 | describe('schema-validator', () => { 26 | it('should validate a schema correctly', () => { 27 | expect(() => schemaValidator(schema, body)).not.toThrow(); 28 | }); 29 | 30 | it('should throw an error if the schema is invalid', () => { 31 | const badBody = { 32 | ...body, 33 | firstName: null, 34 | }; 35 | expect(() => 36 | schemaValidator(schema, badBody) 37 | ).toThrowErrorMatchingInlineSnapshot( 38 | `"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /onion-sounds/packages/schema-validator/schema-validator.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { ValidationError } from '@errors/validation-error'; 3 | import addFormats from 'ajv-formats'; 4 | 5 | export function schemaValidator(schema: Record, body: any) { 6 | const ajv = new Ajv({ 7 | allErrors: true, 8 | }); 9 | 10 | addFormats(ajv); 11 | ajv.addSchema(schema); 12 | 13 | const valid = ajv.validate(schema, body); 14 | 15 | if (!valid) { 16 | const errorMessage = JSON.stringify(ajv.errors); 17 | throw new ValidationError(errorMessage); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /onion-sounds/stateful/stateful.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; 3 | import * as events from 'aws-cdk-lib/aws-events'; 4 | import * as logs from 'aws-cdk-lib/aws-logs'; 5 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 6 | 7 | import { Construct } from 'constructs'; 8 | import { RemovalPolicy } from 'aws-cdk-lib'; 9 | 10 | export class OnionSoundsStatefulStack extends cdk.Stack { 11 | public readonly accountsTable: dynamodb.Table; 12 | public readonly accountsEventBus: events.EventBus; 13 | 14 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 15 | super(scope, id, props); 16 | 17 | // write all of the events to logs so we can track as a catch all 18 | const onionSoundsLogs: logs.LogGroup = new logs.LogGroup( 19 | this, 20 | 'onion-sounds-event-logs', 21 | { 22 | logGroupName: 'onion-sounds-event-logs', 23 | removalPolicy: RemovalPolicy.DESTROY, 24 | } 25 | ); 26 | 27 | // create the dynamodb table for storing our orders 28 | this.accountsTable = new dynamodb.Table(this, 'OnionCustomersTable', { 29 | billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, 30 | encryption: dynamodb.TableEncryption.AWS_MANAGED, 31 | pointInTimeRecovery: false, 32 | contributorInsightsEnabled: true, 33 | removalPolicy: RemovalPolicy.DESTROY, 34 | partitionKey: { 35 | name: 'id', 36 | type: dynamodb.AttributeType.STRING, 37 | }, 38 | }); 39 | 40 | this.accountsEventBus = new events.EventBus( 41 | this, 42 | 'OnionCustomersEventBus', 43 | { 44 | eventBusName: 'onion-customers-event-bus', 45 | } 46 | ); 47 | this.accountsEventBus.applyRemovalPolicy(RemovalPolicy.DESTROY); 48 | 49 | new events.Rule(this, 'LogAllEventsToCloudwatch', { 50 | eventBus: this.accountsEventBus, 51 | ruleName: 'LogAllEventsToCloudwatch', 52 | description: 'log all orders events', 53 | eventPattern: { 54 | source: [{ prefix: '' }] as any[], // match all events 55 | }, 56 | targets: [new targets.CloudWatchLogGroup(onionSoundsLogs)], 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/add-song-to-playlist/add-song-to-playlist.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import * as addSongToPlaylistUseCase from '@use-cases/add-song-to-playlist/add-song-to-playlist'; 2 | 3 | import { 4 | CustomerPlaylistDto, 5 | NewCustomerPlaylistSongDto, 6 | } from '@dto/customer-playlist'; 7 | 8 | import { APIGatewayProxyEvent } from 'aws-lambda'; 9 | import { addSongToPlaylistAdapter } from '@adapters/primary/add-song-to-playlist/add-song-to-playlist.adapter'; 10 | 11 | let event: Partial; 12 | let customerPlaylist: CustomerPlaylistDto; 13 | 14 | describe('add-song-to-playlist-handler', () => { 15 | afterAll(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | beforeEach(() => { 20 | customerPlaylist = { 21 | id: '111', 22 | playlistName: 'newplaylist', 23 | created: 'created', 24 | updated: 'updated', 25 | songIds: ['songone'], 26 | }; 27 | 28 | jest 29 | .spyOn(addSongToPlaylistUseCase, 'addSongToPlaylistUseCase') 30 | .mockResolvedValue(customerPlaylist); 31 | 32 | const payload: NewCustomerPlaylistSongDto = { 33 | songId: 'songone', 34 | }; 35 | 36 | event = { 37 | body: JSON.stringify(payload), 38 | pathParameters: { 39 | id: '111', 40 | playlistId: '222', 41 | }, 42 | }; 43 | }); 44 | 45 | it('should return the correct response on success', async () => { 46 | // act & assert 47 | await expect(addSongToPlaylistAdapter(event as any)).resolves 48 | .toMatchInlineSnapshot(` 49 | Object { 50 | "body": "{\\"id\\":\\"111\\",\\"playlistName\\":\\"newplaylist\\",\\"created\\":\\"created\\",\\"updated\\":\\"updated\\",\\"songIds\\":[\\"songone\\"]}", 51 | "statusCode": 201, 52 | } 53 | `); 54 | }); 55 | 56 | it('should throw a validation error if the payload is invalid', async () => { 57 | // arrange 58 | const payload: NewCustomerPlaylistSongDto = { 59 | songId: '±', // invalid 60 | }; 61 | 62 | event = { 63 | body: JSON.stringify(payload), 64 | pathParameters: { 65 | id: '111', 66 | playlistId: '222', 67 | }, 68 | }; 69 | 70 | // act & assert 71 | await expect(addSongToPlaylistAdapter(event as any)).resolves 72 | .toMatchInlineSnapshot(` 73 | Object { 74 | "body": "\\"[{\\\\\\"instancePath\\\\\\":\\\\\\"/songId\\\\\\",\\\\\\"schemaPath\\\\\\":\\\\\\"#/properties/songId/pattern\\\\\\",\\\\\\"keyword\\\\\\":\\\\\\"pattern\\\\\\",\\\\\\"params\\\\\\":{\\\\\\"pattern\\\\\\":\\\\\\"^[a-zA-Z]+$\\\\\\"},\\\\\\"message\\\\\\":\\\\\\"must match pattern \\\\\\\\\\\\\\"^[a-zA-Z]+$\\\\\\\\\\\\\\"\\\\\\"}]\\"", 75 | "statusCode": 400, 76 | } 77 | `); 78 | }); 79 | 80 | it('should throw a validation error if there are no path parameters', async () => { 81 | // arrange 82 | const payload: NewCustomerPlaylistSongDto = { 83 | songId: 'songone', 84 | }; 85 | 86 | event = { 87 | body: JSON.stringify(payload), 88 | pathParameters: null, 89 | }; 90 | 91 | // act / assert 92 | await expect(addSongToPlaylistAdapter(event as any)).resolves 93 | .toMatchInlineSnapshot(` 94 | Object { 95 | "body": "\\"no customer account id in the path parameters of the event\\"", 96 | "statusCode": 400, 97 | } 98 | `); 99 | }); 100 | 101 | it('should throw a validation error if there is no account id', async () => { 102 | // arrange 103 | const payload: NewCustomerPlaylistSongDto = { 104 | songId: 'songone', 105 | }; 106 | 107 | event = { 108 | body: JSON.stringify(payload), 109 | pathParameters: {}, // no account id 110 | }; 111 | 112 | // act & assert 113 | await expect(addSongToPlaylistAdapter(event as any)).resolves 114 | .toMatchInlineSnapshot(` 115 | Object { 116 | "body": "\\"no customer account id in the path parameters of the event\\"", 117 | "statusCode": 400, 118 | } 119 | `); 120 | }); 121 | 122 | it('should throw a validation error if there is no playlist id', async () => { 123 | // arrange 124 | const payload: NewCustomerPlaylistSongDto = { 125 | songId: 'songone', 126 | }; 127 | 128 | event = { 129 | body: JSON.stringify(payload), 130 | pathParameters: { 131 | id: '111', 132 | }, // no playlist id 133 | }; 134 | 135 | // act & assert 136 | await expect(addSongToPlaylistAdapter(event as any)).resolves 137 | .toMatchInlineSnapshot(` 138 | Object { 139 | "body": "\\"no playlist id in the path parameters of the event\\"", 140 | "statusCode": 400, 141 | } 142 | `); 143 | }); 144 | 145 | it('should throw a validation error if there is no body', async () => { 146 | // arrange 147 | event = {} as any; 148 | 149 | // act & assert 150 | await expect(addSongToPlaylistAdapter(event as any)).resolves 151 | .toMatchInlineSnapshot(` 152 | Object { 153 | "body": "\\"no song body\\"", 154 | "statusCode": 400, 155 | } 156 | `); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/add-song-to-playlist/add-song-to-playlist.adapter.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 2 | import { 3 | CustomerPlaylistDto, 4 | NewCustomerPlaylistSongDto, 5 | } from '@dto/customer-playlist'; 6 | import { 7 | MetricUnits, 8 | Metrics, 9 | logMetrics, 10 | } from '@aws-lambda-powertools/metrics'; 11 | import { Tracer, captureLambdaHandler } from '@aws-lambda-powertools/tracer'; 12 | 13 | import { ValidationError } from '@errors/validation-error'; 14 | import { addSongToPlaylistUseCase } from '@use-cases/add-song-to-playlist'; 15 | import { errorHandler } from '@packages/apigw-error-handler'; 16 | import { injectLambdaContext } from '@aws-lambda-powertools/logger'; 17 | import { logger } from '@packages/logger'; 18 | import middy from '@middy/core'; 19 | import { schema } from './add-song-to-playlist.schema'; 20 | import { schemaValidator } from '@packages/schema-validator'; 21 | 22 | const tracer = new Tracer(); 23 | const metrics = new Metrics(); 24 | 25 | // (primary adapter) --> use case --> secondary adapter(s) 26 | export const addSongToPlaylistAdapter = async ({ 27 | body, 28 | pathParameters, 29 | }: APIGatewayProxyEvent): Promise => { 30 | try { 31 | if (!body) throw new ValidationError('no song body'); 32 | if (!pathParameters || !pathParameters?.id) 33 | throw new ValidationError( 34 | 'no customer account id in the path parameters of the event' 35 | ); 36 | if (!pathParameters || !pathParameters?.playlistId) 37 | throw new ValidationError( 38 | 'no playlist id in the path parameters of the event' 39 | ); 40 | 41 | const { id, playlistId } = pathParameters; 42 | 43 | logger.info(`customer account id: ${id}, playlist id: ${playlistId}`); 44 | 45 | const newCustomerPlaylistSong: NewCustomerPlaylistSongDto = 46 | JSON.parse(body); 47 | 48 | schemaValidator(schema, newCustomerPlaylistSong); 49 | 50 | const updatedPlaylist: CustomerPlaylistDto = await addSongToPlaylistUseCase( 51 | id, 52 | playlistId, 53 | newCustomerPlaylistSong 54 | ); 55 | 56 | logger.info( 57 | `song ${newCustomerPlaylistSong.songId} added to playlist ${playlistId} for account ${id}` 58 | ); 59 | 60 | metrics.addMetric('SuccessfulAddSongToPlaylist', MetricUnits.Count, 1); 61 | 62 | return { 63 | statusCode: 201, 64 | body: JSON.stringify(updatedPlaylist), 65 | }; 66 | } catch (error) { 67 | return errorHandler(error); 68 | } 69 | }; 70 | 71 | export const handler = middy(addSongToPlaylistAdapter) 72 | .use(injectLambdaContext(logger)) 73 | .use(captureLambdaHandler(tracer)) 74 | .use(logMetrics(metrics)); 75 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/add-song-to-playlist/add-song-to-playlist.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { schema } from './add-song-to-playlist.schema'; 2 | import { schemaValidator } from '@packages/schema-validator'; 3 | 4 | describe('add-song-to-playlist-schema', () => { 5 | it('should validate successfully a valid object', () => { 6 | // arrange 7 | const payload = { 8 | songId: 'valid', 9 | }; 10 | // act / assert 11 | expect(() => schemaValidator(schema, payload)).not.toThrow(); 12 | }); 13 | 14 | it('should not validate if the songId is invalid', () => { 15 | // arrange 16 | const payload = { 17 | songId: '±', // invalid 18 | }; 19 | // act / assert 20 | expect(() => 21 | schemaValidator(schema, payload) 22 | ).toThrowErrorMatchingInlineSnapshot( 23 | `"[{\\"instancePath\\":\\"/songId\\",\\"schemaPath\\":\\"#/properties/songId/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 24 | ); 25 | }); 26 | 27 | it('should not validate if the songId is null', () => { 28 | // arrange 29 | const payload = { 30 | songId: null, // invalid 31 | }; 32 | // act / assert 33 | expect(() => 34 | schemaValidator(schema, payload) 35 | ).toThrowErrorMatchingInlineSnapshot( 36 | `"[{\\"instancePath\\":\\"/songId\\",\\"schemaPath\\":\\"#/properties/songId/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/add-song-to-playlist/add-song-to-playlist.schema.ts: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: 'object', 3 | required: ['songId'], 4 | maxProperties: 1, 5 | minProperties: 1, 6 | properties: { 7 | songId: { 8 | type: 'string', 9 | pattern: '^[a-zA-Z]+$', 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-account/create-customer-account.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import * as createCustomerAccountUseCase from '@use-cases/create-customer-account/create-customer-account'; 2 | 3 | import { 4 | CustomerAccountDto, 5 | NewCustomerAccountDto, 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@dto/customer-account'; 9 | 10 | import { APIGatewayProxyEvent } from 'aws-lambda'; 11 | import { createCustomerAccountAdapter } from '@adapters/primary/create-customer-account/create-customer-account.adapter'; 12 | 13 | let event: Partial; 14 | let customerAccount: CustomerAccountDto; 15 | 16 | describe('create-customer-account-handler', () => { 17 | afterAll(() => { 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | beforeEach(() => { 22 | customerAccount = { 23 | id: '111', 24 | firstName: 'Lee', 25 | surname: 'Gilmore', 26 | subscriptionType: SubscriptionType.Basic, 27 | paymentStatus: PaymentStatus.Valid, 28 | created: 'created', 29 | updated: 'updated', 30 | playlists: [], 31 | customerAddress: { 32 | addressLineOne: 'line one', 33 | addressLineTwo: 'line two', 34 | addressLineThree: 'line three', 35 | addressLineFour: 'line four', 36 | addressLineFive: 'line five', 37 | postCode: 'ne11bb', 38 | }, 39 | }; 40 | 41 | jest 42 | .spyOn(createCustomerAccountUseCase, 'createCustomerAccountUseCase') 43 | .mockResolvedValue(customerAccount); 44 | 45 | const payload: NewCustomerAccountDto = { 46 | firstName: 'Lee', 47 | surname: 'Gilmore', 48 | customerAddress: { 49 | addressLineOne: 'line one', 50 | addressLineTwo: 'line two', 51 | addressLineThree: 'line three', 52 | addressLineFour: 'line four', 53 | addressLineFive: 'line five', 54 | postCode: 'ne11bb', 55 | }, 56 | }; 57 | 58 | event = { 59 | body: JSON.stringify(payload), 60 | }; 61 | }); 62 | 63 | it('should return the correct response on success', async () => { 64 | // act & assert 65 | await expect(createCustomerAccountAdapter(event as any)).resolves 66 | .toMatchInlineSnapshot(` 67 | Object { 68 | "body": "{\\"id\\":\\"111\\",\\"firstName\\":\\"Lee\\",\\"surname\\":\\"Gilmore\\",\\"subscriptionType\\":\\"Basic\\",\\"paymentStatus\\":\\"Valid\\",\\"created\\":\\"created\\",\\"updated\\":\\"updated\\",\\"playlists\\":[],\\"customerAddress\\":{\\"addressLineOne\\":\\"line one\\",\\"addressLineTwo\\":\\"line two\\",\\"addressLineThree\\":\\"line three\\",\\"addressLineFour\\":\\"line four\\",\\"addressLineFive\\":\\"line five\\",\\"postCode\\":\\"ne11bb\\"}}", 69 | "statusCode": 201, 70 | } 71 | `); 72 | }); 73 | 74 | it('should throw a validation error if the payload is invalid', async () => { 75 | // arrange 76 | const payload: NewCustomerAccountDto = { 77 | firstName: '', // invalid 78 | surname: 'Gilmore', 79 | customerAddress: { 80 | addressLineOne: 'line one', 81 | addressLineTwo: 'line two', 82 | addressLineThree: 'line three', 83 | addressLineFour: 'line four', 84 | addressLineFive: 'line five', 85 | postCode: 'ne11bb', 86 | }, 87 | }; 88 | 89 | event = { 90 | body: JSON.stringify(payload), 91 | }; 92 | 93 | // act & assert 94 | await expect(createCustomerAccountAdapter(event as any)).resolves 95 | .toMatchInlineSnapshot(` 96 | Object { 97 | "body": "\\"[{\\\\\\"instancePath\\\\\\":\\\\\\"/firstName\\\\\\",\\\\\\"schemaPath\\\\\\":\\\\\\"#/properties/firstName/pattern\\\\\\",\\\\\\"keyword\\\\\\":\\\\\\"pattern\\\\\\",\\\\\\"params\\\\\\":{\\\\\\"pattern\\\\\\":\\\\\\"^[a-zA-Z]+$\\\\\\"},\\\\\\"message\\\\\\":\\\\\\"must match pattern \\\\\\\\\\\\\\"^[a-zA-Z]+$\\\\\\\\\\\\\\"\\\\\\"}]\\"", 98 | "statusCode": 400, 99 | } 100 | `); 101 | }); 102 | 103 | it('should return the correct response on error', async () => { 104 | // arrange 105 | event = {} as any; 106 | 107 | // act & assert 108 | await expect(createCustomerAccountAdapter(event as any)).resolves 109 | .toMatchInlineSnapshot(` 110 | Object { 111 | "body": "\\"no order body\\"", 112 | "statusCode": 400, 113 | } 114 | `); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-account/create-customer-account.adapter.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 2 | import { 3 | CustomerAccountDto, 4 | NewCustomerAccountDto, 5 | } from '@dto/customer-account'; 6 | import { 7 | MetricUnits, 8 | Metrics, 9 | logMetrics, 10 | } from '@aws-lambda-powertools/metrics'; 11 | import { Tracer, captureLambdaHandler } from '@aws-lambda-powertools/tracer'; 12 | 13 | import { ValidationError } from '@errors/validation-error'; 14 | import { createCustomerAccountUseCase } from '@use-cases/create-customer-account'; 15 | import { errorHandler } from '@packages/apigw-error-handler'; 16 | import { injectLambdaContext } from '@aws-lambda-powertools/logger'; 17 | import { logger } from '@packages/logger'; 18 | import middy from '@middy/core'; 19 | import { schema } from './create-customer-account.schema'; 20 | import { schemaValidator } from '@packages/schema-validator'; 21 | 22 | const tracer = new Tracer(); 23 | const metrics = new Metrics(); 24 | 25 | // (primary adapter) --> use case --> (secondary adapter) 26 | export const createCustomerAccountAdapter = async ({ 27 | body, 28 | }: APIGatewayProxyEvent): Promise => { 29 | try { 30 | if (!body) throw new ValidationError('no order body'); 31 | 32 | const customerAccount: NewCustomerAccountDto = JSON.parse(body); 33 | 34 | schemaValidator(schema, customerAccount); 35 | logger.info(`customer account: ${JSON.stringify(customerAccount)}`); 36 | 37 | const createdAccount: CustomerAccountDto = 38 | await createCustomerAccountUseCase(customerAccount); 39 | 40 | logger.info(`customer account created: ${JSON.stringify(createdAccount)}`); 41 | 42 | metrics.addMetric('SuccessfulCustomerAccountCreated', MetricUnits.Count, 1); 43 | 44 | return { 45 | statusCode: 201, 46 | body: JSON.stringify(createdAccount), 47 | }; 48 | } catch (error) { 49 | return errorHandler(error); 50 | } 51 | }; 52 | 53 | export const handler = middy(createCustomerAccountAdapter) 54 | .use(injectLambdaContext(logger)) 55 | .use(captureLambdaHandler(tracer)) 56 | .use(logMetrics(metrics)); 57 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-account/create-customer-account.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { schema } from './create-customer-account.schema'; 2 | import { schemaValidator } from '@packages/schema-validator'; 3 | 4 | describe('create-customer-account-schema', () => { 5 | it('should validate successfully a valid object', () => { 6 | // arrange 7 | const payload = { 8 | firstName: 'Lee', 9 | surname: 'Gilmore', 10 | customerAddress: { 11 | addressLineOne: 'line one', 12 | addressLineTwo: 'line two', 13 | addressLineThree: 'line three', 14 | addressLineFour: 'line four', 15 | addressLineFive: 'line five', 16 | postCode: 'ne11bb', 17 | }, 18 | }; 19 | // act / assert 20 | expect(() => schemaValidator(schema, payload)).not.toThrow(); 21 | }); 22 | 23 | it('should not validate if the firstName is null', () => { 24 | // arrange 25 | const payload = { 26 | firstName: null, // invalid 27 | surname: 'Gilmore', 28 | customerAddress: { 29 | addressLineOne: 'line one', 30 | addressLineTwo: 'line two', 31 | addressLineThree: 'line three', 32 | addressLineFour: 'line four', 33 | addressLineFive: 'line five', 34 | postCode: 'ne11bb', 35 | }, 36 | }; 37 | // act / assert 38 | expect(() => 39 | schemaValidator(schema, payload) 40 | ).toThrowErrorMatchingInlineSnapshot( 41 | `"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 42 | ); 43 | }); 44 | 45 | it('should not validate if the firstName is invalid', () => { 46 | const payload = { 47 | firstName: '±', // invalid 48 | surname: null, 49 | customerAddress: { 50 | addressLineOne: 'line one', 51 | addressLineTwo: 'line two', 52 | addressLineThree: 'line three', 53 | addressLineFour: 'line four', 54 | addressLineFive: 'line five', 55 | postCode: 'ne11bb', 56 | }, 57 | }; 58 | expect(() => 59 | schemaValidator(schema, payload) 60 | ).toThrowErrorMatchingInlineSnapshot( 61 | `"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"},{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 62 | ); 63 | }); 64 | 65 | it('should not validate if the surname is null', () => { 66 | // arrange 67 | const payload = { 68 | firstName: 'Lee', 69 | surname: null, // invalid 70 | customerAddress: { 71 | addressLineOne: 'line one', 72 | addressLineTwo: 'line two', 73 | addressLineThree: 'line three', 74 | addressLineFour: 'line four', 75 | addressLineFive: 'line five', 76 | postCode: 'ne11bb', 77 | }, 78 | }; 79 | // act / assert 80 | expect(() => 81 | schemaValidator(schema, payload) 82 | ).toThrowErrorMatchingInlineSnapshot( 83 | `"[{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 84 | ); 85 | }); 86 | 87 | it('should not validate if the firstName is invalid', () => { 88 | // arrange 89 | const payload = { 90 | firstName: 'Lee', 91 | surname: '±', // invalid 92 | customerAddress: { 93 | addressLineOne: 'line one', 94 | addressLineTwo: 'line two', 95 | addressLineThree: 'line three', 96 | addressLineFour: 'line four', 97 | addressLineFive: 'line five', 98 | postCode: 'ne11bb', 99 | }, 100 | }; 101 | // act / assert 102 | expect(() => 103 | schemaValidator(schema, payload) 104 | ).toThrowErrorMatchingInlineSnapshot( 105 | `"[{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 106 | ); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-account/create-customer-account.schema.ts: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: 'object', 3 | required: ['firstName', 'surname', 'customerAddress'], 4 | maxProperties: 3, 5 | minProperties: 3, 6 | properties: { 7 | firstName: { 8 | type: 'string', 9 | pattern: '^[a-zA-Z]+$', 10 | }, 11 | surname: { 12 | type: 'string', 13 | pattern: '^[a-zA-Z]+$', 14 | }, 15 | customerAddress: { 16 | type: 'object', 17 | required: ['addressLineOne', 'postCode'], 18 | properties: { 19 | addressLineOne: { 20 | type: 'string', 21 | pattern: '^[a-zA-Z0-9 _.-]+$', 22 | }, 23 | addressLineTwo: { 24 | type: 'string', 25 | pattern: '^[a-zA-Z0-9 _.-]+$', 26 | }, 27 | addressLineThree: { 28 | type: 'string', 29 | pattern: '^[a-zA-Z0-9 _.-]+$', 30 | }, 31 | addressLineFour: { 32 | type: 'string', 33 | pattern: '^[a-zA-Z0-9 _.-]+$', 34 | }, 35 | addressLineFive: { 36 | type: 'string', 37 | pattern: '^[a-zA-Z0-9 _.-]+$', 38 | }, 39 | postCode: { 40 | type: 'string', 41 | pattern: '^[a-zA-Z0-9 _.-]+$', 42 | }, 43 | }, 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-playlist/create-customer-playlist.adpater.test.ts: -------------------------------------------------------------------------------- 1 | import * as createCustomerPlaylistUseCase from '@use-cases/create-customer-playlist/create-customer-playlist'; 2 | 3 | import { 4 | CustomerPlaylistDto, 5 | NewCustomerPlaylistDto, 6 | } from '@dto/customer-playlist'; 7 | 8 | import { APIGatewayProxyEvent } from 'aws-lambda'; 9 | import { createCustomerPlaylistAdapter } from '@adapters/primary/create-customer-playlist/create-customer-playlist.adpater'; 10 | 11 | let event: Partial; 12 | let customerPlaylist: CustomerPlaylistDto; 13 | 14 | describe('create-customer-playlist-handler', () => { 15 | afterAll(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | beforeEach(() => { 20 | customerPlaylist = { 21 | id: '111', 22 | playlistName: 'newplaylist', 23 | created: 'created', 24 | updated: 'updated', 25 | songIds: [], 26 | }; 27 | 28 | jest 29 | .spyOn(createCustomerPlaylistUseCase, 'createCustomerPlaylistUseCase') 30 | .mockResolvedValue(customerPlaylist); 31 | 32 | const payload: NewCustomerPlaylistDto = { 33 | playlistName: 'newplaylist', 34 | }; 35 | 36 | event = { 37 | body: JSON.stringify(payload), 38 | pathParameters: { 39 | id: '111', 40 | }, 41 | }; 42 | }); 43 | 44 | it('should return the correct response on success', async () => { 45 | // act & assert 46 | await expect(createCustomerPlaylistAdapter(event as any)).resolves 47 | .toMatchInlineSnapshot(` 48 | Object { 49 | "body": "{\\"id\\":\\"111\\",\\"playlistName\\":\\"newplaylist\\",\\"created\\":\\"created\\",\\"updated\\":\\"updated\\",\\"songIds\\":[]}", 50 | "statusCode": 201, 51 | } 52 | `); 53 | }); 54 | 55 | it('should throw a validation error if the payload is invalid', async () => { 56 | // arrange 57 | const payload: NewCustomerPlaylistDto = { 58 | playlistName: '±', // invalid 59 | }; 60 | 61 | event = { 62 | body: JSON.stringify(payload), 63 | pathParameters: { 64 | id: '111', 65 | }, 66 | }; 67 | 68 | // act & assert 69 | await expect(createCustomerPlaylistAdapter(event as any)).resolves 70 | .toMatchInlineSnapshot(` 71 | Object { 72 | "body": "\\"[{\\\\\\"instancePath\\\\\\":\\\\\\"/playlistName\\\\\\",\\\\\\"schemaPath\\\\\\":\\\\\\"#/properties/playlistName/pattern\\\\\\",\\\\\\"keyword\\\\\\":\\\\\\"pattern\\\\\\",\\\\\\"params\\\\\\":{\\\\\\"pattern\\\\\\":\\\\\\"^[a-zA-Z]+$\\\\\\"},\\\\\\"message\\\\\\":\\\\\\"must match pattern \\\\\\\\\\\\\\"^[a-zA-Z]+$\\\\\\\\\\\\\\"\\\\\\"}]\\"", 73 | "statusCode": 400, 74 | } 75 | `); 76 | }); 77 | 78 | it('should throw a validation error if there is no account id', async () => { 79 | // arrange 80 | const payload: NewCustomerPlaylistDto = { 81 | playlistName: 'newplaylist', 82 | }; 83 | 84 | event = { 85 | body: JSON.stringify(payload), 86 | pathParameters: {}, // no account id 87 | }; 88 | 89 | // act & assert 90 | await expect(createCustomerPlaylistAdapter(event as any)).resolves 91 | .toMatchInlineSnapshot(` 92 | Object { 93 | "body": "\\"no id in the path parameters of the event\\"", 94 | "statusCode": 400, 95 | } 96 | `); 97 | }); 98 | 99 | it('should throw a validation error if there is no body', async () => { 100 | // arrange 101 | event = {} as any; 102 | 103 | // act & assert 104 | await expect(createCustomerPlaylistAdapter(event as any)).resolves 105 | .toMatchInlineSnapshot(` 106 | Object { 107 | "body": "\\"no playlist body\\"", 108 | "statusCode": 400, 109 | } 110 | `); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-playlist/create-customer-playlist.adpater.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 2 | import { 3 | CustomerPlaylistDto, 4 | NewCustomerPlaylistDto, 5 | } from '@dto/customer-playlist'; 6 | import { 7 | MetricUnits, 8 | Metrics, 9 | logMetrics, 10 | } from '@aws-lambda-powertools/metrics'; 11 | import { Tracer, captureLambdaHandler } from '@aws-lambda-powertools/tracer'; 12 | 13 | import { ValidationError } from '@errors/validation-error'; 14 | import { createCustomerPlaylistUseCase } from '@use-cases/create-customer-playlist'; 15 | import { errorHandler } from '@packages/apigw-error-handler'; 16 | import { injectLambdaContext } from '@aws-lambda-powertools/logger'; 17 | import { logger } from '@packages/logger'; 18 | import middy from '@middy/core'; 19 | import { schema } from './create-customer-playlist.schema'; 20 | import { schemaValidator } from '@packages/schema-validator'; 21 | 22 | const tracer = new Tracer(); 23 | const metrics = new Metrics(); 24 | 25 | // (adapter) --> use case --> secondary adapter(s) 26 | export const createCustomerPlaylistAdapter = async ({ 27 | body, 28 | pathParameters, 29 | }: APIGatewayProxyEvent): Promise => { 30 | try { 31 | if (!body) throw new ValidationError('no playlist body'); 32 | if (!pathParameters || !pathParameters?.id) 33 | throw new ValidationError('no id in the path parameters of the event'); 34 | 35 | const { id } = pathParameters; 36 | 37 | logger.info(`customer account id: ${id}`); 38 | 39 | const customerPlaylist: NewCustomerPlaylistDto = JSON.parse(body); 40 | 41 | schemaValidator(schema, customerPlaylist); 42 | 43 | logger.info(`customer account: ${JSON.stringify(customerPlaylist)}`); 44 | 45 | const createdAccount: CustomerPlaylistDto = 46 | await createCustomerPlaylistUseCase(id, customerPlaylist); 47 | 48 | logger.info(`customer account created: ${JSON.stringify(createdAccount)}`); 49 | 50 | metrics.addMetric( 51 | 'SuccessfulCustomerPlaylistCreated', 52 | MetricUnits.Count, 53 | 1 54 | ); 55 | 56 | return { 57 | statusCode: 201, 58 | body: JSON.stringify(createdAccount), 59 | }; 60 | } catch (error) { 61 | return errorHandler(error); 62 | } 63 | }; 64 | 65 | export const handler = middy(createCustomerPlaylistAdapter) 66 | .use(injectLambdaContext(logger)) 67 | .use(captureLambdaHandler(tracer)) 68 | .use(logMetrics(metrics)); 69 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-playlist/create-customer-playlist.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { schema } from './create-customer-playlist.schema'; 2 | import { schemaValidator } from '@packages/schema-validator'; 3 | 4 | describe('create-playlist-schema', () => { 5 | it('should validate successfully a valid object', () => { 6 | // arrange 7 | const payload = { 8 | playlistName: 'valid', 9 | }; 10 | // act / assert 11 | expect(() => schemaValidator(schema, payload)).not.toThrow(); 12 | }); 13 | 14 | it('should not validate if the playlistName is invalid', () => { 15 | // arrange 16 | const payload = { 17 | playlistName: '±', // invalid 18 | }; 19 | // act / assert 20 | expect(() => 21 | schemaValidator(schema, payload)). 22 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/playlistName\\",\\"schemaPath\\":\\"#/properties/playlistName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"`); 23 | }); 24 | 25 | it('should not validate if the playlistName is null', () => { 26 | // arrange 27 | const payload = { 28 | playlistName: null, // invalid 29 | }; 30 | // act / assert 31 | expect(() => 32 | schemaValidator(schema, payload) 33 | ).toThrowErrorMatchingInlineSnapshot( 34 | `"[{\\"instancePath\\":\\"/playlistName\\",\\"schemaPath\\":\\"#/properties/playlistName/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/create-customer-playlist/create-customer-playlist.schema.ts: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: 'object', 3 | required: ['playlistName'], 4 | maxProperties: 1, 5 | minProperties: 1, 6 | properties: { 7 | playlistName: { 8 | type: 'string', 9 | pattern: '^[a-zA-Z]+$', 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/retrieve-customer-account/retrieve-customer-account.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import * as retrieveCustomerAccountUseCase from '@use-cases/retrieve-customer-account/retrieve-customer-account'; 2 | 3 | import { 4 | CustomerAccountDto, 5 | PaymentStatus, 6 | SubscriptionType, 7 | } from '@dto/customer-account'; 8 | 9 | import { APIGatewayProxyEvent } from 'aws-lambda'; 10 | import { retrieveCustomerAccountAdapter } from '@adapters/primary/retrieve-customer-account/retrieve-customer-account.adapter'; 11 | 12 | let event: Partial; 13 | let customerAccount: CustomerAccountDto; 14 | 15 | describe('retrieve-customer-account-handler', () => { 16 | afterAll(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | beforeEach(() => { 21 | customerAccount = { 22 | id: '111', 23 | firstName: 'Lee', 24 | surname: 'Gilmore', 25 | subscriptionType: SubscriptionType.Basic, 26 | paymentStatus: PaymentStatus.Valid, 27 | created: 'created', 28 | updated: 'updated', 29 | playlists: [ 30 | { 31 | id: '2222', 32 | songIds: ['111', '222'], 33 | playlistName: 'playlistOne', 34 | created: 'created', 35 | updated: 'updated', 36 | }, 37 | ], 38 | customerAddress: { 39 | addressLineOne: 'line one', 40 | addressLineTwo: 'line two', 41 | addressLineThree: 'line three', 42 | addressLineFour: 'line four', 43 | addressLineFive: 'line five', 44 | postCode: 'ne11bb', 45 | }, 46 | }; 47 | 48 | jest 49 | .spyOn(retrieveCustomerAccountUseCase, 'retrieveCustomerAccountUseCase') 50 | .mockResolvedValue(customerAccount); 51 | 52 | event = { 53 | pathParameters: { 54 | id: '111', 55 | }, 56 | }; 57 | }); 58 | 59 | it('should return the correct response on success', async () => { 60 | // act & assert 61 | await expect(retrieveCustomerAccountAdapter((event as any))).resolves. 62 | toMatchInlineSnapshot(` 63 | Object { 64 | "body": "{\\"id\\":\\"111\\",\\"firstName\\":\\"Lee\\",\\"surname\\":\\"Gilmore\\",\\"subscriptionType\\":\\"Basic\\",\\"paymentStatus\\":\\"Valid\\",\\"created\\":\\"created\\",\\"updated\\":\\"updated\\",\\"playlists\\":[{\\"id\\":\\"2222\\",\\"songIds\\":[\\"111\\",\\"222\\"],\\"playlistName\\":\\"playlistOne\\",\\"created\\":\\"created\\",\\"updated\\":\\"updated\\"}],\\"customerAddress\\":{\\"addressLineOne\\":\\"line one\\",\\"addressLineTwo\\":\\"line two\\",\\"addressLineThree\\":\\"line three\\",\\"addressLineFour\\":\\"line four\\",\\"addressLineFive\\":\\"line five\\",\\"postCode\\":\\"ne11bb\\"}}", 65 | "statusCode": 200, 66 | } 67 | `); 68 | }); 69 | 70 | it('should return the correct response on error', async () => { 71 | // arrange 72 | event = {} as any; 73 | 74 | // act & assert 75 | await expect(retrieveCustomerAccountAdapter(event as any)).resolves 76 | .toMatchInlineSnapshot(` 77 | Object { 78 | "body": "\\"no id in the path parameters of the event\\"", 79 | "statusCode": 400, 80 | } 81 | `); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/retrieve-customer-account/retrieve-customer-account.adapter.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 2 | import { 3 | MetricUnits, 4 | Metrics, 5 | logMetrics, 6 | } from '@aws-lambda-powertools/metrics'; 7 | import { Tracer, captureLambdaHandler } from '@aws-lambda-powertools/tracer'; 8 | 9 | import { CustomerAccountDto } from '@dto/customer-account'; 10 | import { ValidationError } from '@errors/validation-error'; 11 | import { errorHandler } from '@packages/apigw-error-handler'; 12 | import { injectLambdaContext } from '@aws-lambda-powertools/logger'; 13 | import { logger } from '@packages/logger'; 14 | import middy from '@middy/core'; 15 | import { retrieveCustomerAccountUseCase } from '@use-cases/retrieve-customer-account'; 16 | 17 | const tracer = new Tracer(); 18 | const metrics = new Metrics(); 19 | 20 | // (primary adapter) --> use case --> secondary adapter(s) 21 | export const retrieveCustomerAccountAdapter = async ({ 22 | pathParameters, 23 | }: APIGatewayProxyEvent): Promise => { 24 | try { 25 | if (!pathParameters || !pathParameters?.id) 26 | throw new ValidationError('no id in the path parameters of the event'); 27 | 28 | const { id } = pathParameters; 29 | 30 | logger.info(`customer account id: ${id}`); 31 | 32 | const customerAccount: CustomerAccountDto = 33 | await retrieveCustomerAccountUseCase(id); 34 | 35 | logger.info(`customer account: ${JSON.stringify(customerAccount)}`); 36 | 37 | metrics.addMetric( 38 | 'SuccessfulCustomerAccountRecieved', 39 | MetricUnits.Count, 40 | 1 41 | ); 42 | 43 | return { 44 | statusCode: 200, 45 | body: JSON.stringify(customerAccount), 46 | }; 47 | } catch (error) { 48 | return errorHandler(error); 49 | } 50 | }; 51 | 52 | export const handler = middy(retrieveCustomerAccountAdapter) 53 | .use(injectLambdaContext(logger)) 54 | .use(captureLambdaHandler(tracer)) 55 | .use(logMetrics(metrics)); 56 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/upgrade-customer-account/upgrade-customer-account.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import * as upgradeCustomerAccountUseCase from '@use-cases/upgrade-customer-account/upgrade-customer-account'; 2 | 3 | import { 4 | CustomerAccountDto, 5 | PaymentStatus, 6 | SubscriptionType, 7 | } from '@dto/customer-account'; 8 | 9 | import { APIGatewayProxyEvent } from 'aws-lambda'; 10 | import { upgradeCustomerAccountAdapter } from '@adapters/primary/upgrade-customer-account/upgrade-customer-account.adapter'; 11 | 12 | let event: Partial; 13 | let customerAccount: CustomerAccountDto; 14 | 15 | describe('upgrade-customer-account-handler', () => { 16 | afterAll(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | beforeEach(() => { 21 | customerAccount = { 22 | id: '111', 23 | firstName: 'Lee', 24 | surname: 'Gilmore', 25 | subscriptionType: SubscriptionType.Upgraded, 26 | paymentStatus: PaymentStatus.Valid, 27 | created: 'created', 28 | updated: 'updated', 29 | playlists: [], 30 | customerAddress: { 31 | addressLineOne: 'line one', 32 | addressLineTwo: 'line two', 33 | addressLineThree: 'line three', 34 | addressLineFour: 'line four', 35 | addressLineFive: 'line five', 36 | postCode: 'ne11bb', 37 | }, 38 | }; 39 | 40 | jest 41 | .spyOn(upgradeCustomerAccountUseCase, 'upgradeCustomerAccountUseCase') 42 | .mockResolvedValue(customerAccount); 43 | 44 | event = { 45 | pathParameters: { 46 | id: '111', 47 | }, 48 | }; 49 | }); 50 | 51 | it('should return the correct response on success', async () => { 52 | // act & assert 53 | await expect(upgradeCustomerAccountAdapter((event as any))).resolves. 54 | toMatchInlineSnapshot(` 55 | Object { 56 | "body": "{\\"id\\":\\"111\\",\\"firstName\\":\\"Lee\\",\\"surname\\":\\"Gilmore\\",\\"subscriptionType\\":\\"Upgraded\\",\\"paymentStatus\\":\\"Valid\\",\\"created\\":\\"created\\",\\"updated\\":\\"updated\\",\\"playlists\\":[],\\"customerAddress\\":{\\"addressLineOne\\":\\"line one\\",\\"addressLineTwo\\":\\"line two\\",\\"addressLineThree\\":\\"line three\\",\\"addressLineFour\\":\\"line four\\",\\"addressLineFive\\":\\"line five\\",\\"postCode\\":\\"ne11bb\\"}}", 57 | "statusCode": 200, 58 | } 59 | `); 60 | }); 61 | 62 | it('should throw an error if no path parameters', async () => { 63 | // arrange 64 | event = { 65 | pathParameters: null, 66 | }; 67 | 68 | // act & assert 69 | await expect(upgradeCustomerAccountAdapter(event as any)).resolves 70 | .toMatchInlineSnapshot(` 71 | Object { 72 | "body": "\\"no id in the path parameters of the event\\"", 73 | "statusCode": 400, 74 | } 75 | `); 76 | }); 77 | 78 | it('should return the correct response on error', async () => { 79 | // arrange 80 | event = {} as any; 81 | 82 | // act & assert 83 | await expect(upgradeCustomerAccountAdapter(event as any)).resolves 84 | .toMatchInlineSnapshot(` 85 | Object { 86 | "body": "\\"no id in the path parameters of the event\\"", 87 | "statusCode": 400, 88 | } 89 | `); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/primary/upgrade-customer-account/upgrade-customer-account.adapter.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 2 | import { 3 | MetricUnits, 4 | Metrics, 5 | logMetrics, 6 | } from '@aws-lambda-powertools/metrics'; 7 | import { Tracer, captureLambdaHandler } from '@aws-lambda-powertools/tracer'; 8 | 9 | import { CustomerAccountDto } from '@dto/customer-account'; 10 | import { ValidationError } from '@errors/validation-error'; 11 | import { errorHandler } from '@packages/apigw-error-handler'; 12 | import { injectLambdaContext } from '@aws-lambda-powertools/logger'; 13 | import { logger } from '@packages/logger'; 14 | import middy from '@middy/core'; 15 | import { upgradeCustomerAccountUseCase } from '@use-cases/upgrade-customer-account'; 16 | 17 | const tracer = new Tracer(); 18 | const metrics = new Metrics(); 19 | 20 | // (primary adapter) --> use case --> secondary adapters(s) 21 | export const upgradeCustomerAccountAdapter = async ({ 22 | pathParameters, 23 | }: APIGatewayProxyEvent): Promise => { 24 | try { 25 | if (!pathParameters || !pathParameters?.id) 26 | throw new ValidationError('no id in the path parameters of the event'); 27 | 28 | const { id } = pathParameters; 29 | 30 | logger.info(`customer account id: ${id}`); 31 | 32 | const customerAccount: CustomerAccountDto = 33 | await upgradeCustomerAccountUseCase(id); 34 | 35 | logger.info( 36 | `upgraded customer account: ${JSON.stringify(customerAccount)}` 37 | ); 38 | 39 | metrics.addMetric( 40 | 'SuccessfulCustomerAccountUpgraded', 41 | MetricUnits.Count, 42 | 1 43 | ); 44 | metrics.addMetadata('CustomerAccountId', id); 45 | 46 | return { 47 | statusCode: 200, 48 | body: JSON.stringify(customerAccount), 49 | }; 50 | } catch (error) { 51 | return errorHandler(error); 52 | } 53 | }; 54 | 55 | export const handler = middy(upgradeCustomerAccountAdapter) 56 | .use(injectLambdaContext(logger)) 57 | .use(captureLambdaHandler(tracer)) 58 | .use(logMetrics(metrics)); 59 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/database-adapter/database-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomerAccountDto, 3 | PaymentStatus, 4 | SubscriptionType, 5 | } from '@dto/customer-account'; 6 | import { 7 | createAccount, 8 | retrieveAccount, 9 | updateAccount, 10 | } from '@adapters/secondary/database-adapter/database-adapter'; 11 | 12 | import { awsSdkGetPromiseResponse } from '../../../../../__mocks__/aws-sdk'; 13 | 14 | let customerAccount: CustomerAccountDto; 15 | 16 | describe('database-adapter', () => { 17 | beforeEach(() => { 18 | customerAccount = { 19 | id: '111', 20 | firstName: 'Gilmore', 21 | surname: 'Lee', 22 | subscriptionType: SubscriptionType.Basic, 23 | paymentStatus: PaymentStatus.Valid, 24 | created: 'created', 25 | updated: 'updated', 26 | playlists: [], 27 | customerAddress: { 28 | addressLineOne: 'line one', 29 | addressLineTwo: 'line two', 30 | addressLineThree: 'line three', 31 | addressLineFour: 'line four', 32 | addressLineFive: 'line five', 33 | postCode: 'ne11bb', 34 | }, 35 | }; 36 | }); 37 | 38 | describe('create-account', () => { 39 | it('should return the correct dto', async () => { 40 | await expect(createAccount(customerAccount)).resolves 41 | .toMatchInlineSnapshot(` 42 | Object { 43 | "created": "created", 44 | "customerAddress": Object { 45 | "addressLineFive": "line five", 46 | "addressLineFour": "line four", 47 | "addressLineOne": "line one", 48 | "addressLineThree": "line three", 49 | "addressLineTwo": "line two", 50 | "postCode": "ne11bb", 51 | }, 52 | "firstName": "Gilmore", 53 | "id": "111", 54 | "paymentStatus": "Valid", 55 | "playlists": Array [], 56 | "subscriptionType": "Basic", 57 | "surname": "Lee", 58 | "updated": "updated", 59 | } 60 | `); 61 | }); 62 | }); 63 | 64 | describe('update-account', () => { 65 | it('should return the correct dto', async () => { 66 | await expect(updateAccount(customerAccount)).resolves 67 | .toMatchInlineSnapshot(` 68 | Object { 69 | "created": "created", 70 | "customerAddress": Object { 71 | "addressLineFive": "line five", 72 | "addressLineFour": "line four", 73 | "addressLineOne": "line one", 74 | "addressLineThree": "line three", 75 | "addressLineTwo": "line two", 76 | "postCode": "ne11bb", 77 | }, 78 | "firstName": "Gilmore", 79 | "id": "111", 80 | "paymentStatus": "Valid", 81 | "playlists": Array [], 82 | "subscriptionType": "Basic", 83 | "surname": "Lee", 84 | "updated": "updated", 85 | } 86 | `); 87 | }); 88 | }); 89 | 90 | describe('retrieve-account', () => { 91 | it('should return the correct dto', async () => { 92 | // arrange 93 | awsSdkGetPromiseResponse.mockResolvedValueOnce({ 94 | Item: { 95 | ...customerAccount, 96 | }, 97 | }); 98 | await expect(retrieveAccount(customerAccount.id)).resolves 99 | .toMatchInlineSnapshot(` 100 | Object { 101 | "created": "created", 102 | "customerAddress": Object { 103 | "addressLineFive": "line five", 104 | "addressLineFour": "line four", 105 | "addressLineOne": "line one", 106 | "addressLineThree": "line three", 107 | "addressLineTwo": "line two", 108 | "postCode": "ne11bb", 109 | }, 110 | "firstName": "Gilmore", 111 | "id": "111", 112 | "paymentStatus": "Valid", 113 | "playlists": Array [], 114 | "subscriptionType": "Basic", 115 | "surname": "Lee", 116 | "updated": "updated", 117 | } 118 | `); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/database-adapter/database-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from 'aws-sdk'; 2 | 3 | import { CustomerAccountDto } from '@dto/customer-account'; 4 | import { config } from '@config/config'; 5 | import { logger } from '@packages/logger'; 6 | 7 | const dynamoDb = new AWS.DynamoDB.DocumentClient(); 8 | 9 | // this is the secondary adapter which creates the account from the db 10 | // Note: you would typically use a module or package here to interact 11 | // with the database technology - for example dynamoose 12 | 13 | // primary adapter --> use case --> (secondary adapter) 14 | export async function createAccount( 15 | customerAccount: CustomerAccountDto 16 | ): Promise { 17 | const tableName = config.get('tableName'); 18 | 19 | const params: AWS.DynamoDB.DocumentClient.PutItemInput = { 20 | TableName: tableName, 21 | Item: customerAccount, 22 | }; 23 | 24 | await dynamoDb.put(params).promise(); 25 | logger.info(`Customer account ${customerAccount.id} stored in ${tableName}`); 26 | 27 | return customerAccount; 28 | } 29 | 30 | // this is the secondary adapter which updates the account in the db 31 | // primary adapter --> use case --> (secondary adapter) 32 | export async function updateAccount( 33 | customerAccount: CustomerAccountDto 34 | ): Promise { 35 | const tableName = config.get('tableName'); 36 | 37 | const params: AWS.DynamoDB.DocumentClient.PutItemInput = { 38 | TableName: tableName, 39 | Item: customerAccount, 40 | }; 41 | 42 | await dynamoDb.put(params).promise(); 43 | logger.info(`Customer account ${customerAccount.id} updated in ${tableName}`); 44 | 45 | return customerAccount; 46 | } 47 | 48 | // this is the secondary adapter which retrieves the account from the db 49 | // primary adapter --> use case --> (secondary adapter) 50 | export async function retrieveAccount(id: string): Promise { 51 | const tableName = config.get('tableName'); 52 | 53 | const params: AWS.DynamoDB.DocumentClient.GetItemInput = { 54 | TableName: tableName, 55 | Key: { 56 | id, 57 | }, 58 | }; 59 | 60 | const { Item: item } = await dynamoDb.get(params).promise(); 61 | 62 | const customer: CustomerAccountDto = { 63 | ...(item as CustomerAccountDto), 64 | }; 65 | 66 | logger.info(`Customer account ${customer.id} retrieved from ${tableName}`); 67 | 68 | return customer; 69 | } 70 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/database-adapter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database-adapter'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/event-adapter/event-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { publishEvent } from '@adapters/secondary/event-adapter'; 2 | describe('event-adapter', () => { 3 | it('should successfully send the event and return void', async () => { 4 | // arrange 5 | const event = { 6 | id: '111', 7 | }; 8 | 9 | // act & assert 10 | await expect( 11 | publishEvent(event, 'detailType', 'source', 'version', 'date-time') 12 | ).resolves.toBeUndefined(); 13 | }); 14 | 15 | it('should throw an error if no properties on the body of the event', async () => { 16 | // arrange 17 | const event = {}; // blank object 18 | 19 | // act & assert 20 | await expect( 21 | publishEvent(event, 'detailType', 'source', 'version', 'date-time') 22 | ).rejects.toMatchInlineSnapshot( 23 | `[NoEventBodyError: There is no body on the event]` 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/event-adapter/event-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from 'aws-sdk'; 2 | 3 | import { PutEventsRequestEntry } from 'aws-sdk/clients/eventbridge'; 4 | import { config } from '@config/config'; 5 | import { logger } from '@packages/logger'; 6 | 7 | class NoEventBodyError extends Error { 8 | constructor(message: string) { 9 | super(message); 10 | this.name = 'NoEventBodyError'; 11 | } 12 | } 13 | 14 | const eventBridge = new AWS.EventBridge(); 15 | 16 | // this is a secondary adapter which will publish the event to eventbridge 17 | // primary adapter --> use case --> (secondary adapter) 18 | export async function publishEvent( 19 | event: Record, 20 | detailType: string, 21 | source: string, 22 | eventVersion: string, 23 | eventDateTime: string 24 | ): Promise { 25 | const eventBus = config.get('eventBus'); 26 | 27 | if (Object.keys(event).length === 0) { 28 | throw new NoEventBodyError('There is no body on the event'); 29 | } 30 | 31 | const createEvent: PutEventsRequestEntry = { 32 | Detail: JSON.stringify({ 33 | metadata: { 34 | eventDateTime: eventDateTime, 35 | eventVersion: eventVersion, 36 | }, 37 | data: { 38 | ...event, 39 | }, 40 | }), 41 | DetailType: detailType, 42 | EventBusName: eventBus, 43 | Source: source, 44 | }; 45 | 46 | const subscriptionEvent: AWS.EventBridge.PutEventsRequest = { 47 | Entries: [createEvent], 48 | }; 49 | 50 | await eventBridge.putEvents(subscriptionEvent).promise(); 51 | 52 | logger.info( 53 | `event ${detailType} published for ${event.id} to bus ${eventBus} with source ${source}` 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/event-adapter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './event-adapter'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/config/config.test.ts: -------------------------------------------------------------------------------- 1 | const OLD_ENV = process.env; 2 | 3 | describe('config', () => { 4 | beforeEach(() => { 5 | jest.resetModules(); 6 | process.env = { ...OLD_ENV }; 7 | }); 8 | 9 | afterAll(() => { 10 | process.env = OLD_ENV; 11 | }); 12 | 13 | describe('table-name', () => { 14 | it('should return the default value', () => { 15 | // arrange 16 | const { config } = require('./config'); 17 | // act / assert 18 | expect(config.get('tableName')).toEqual('tableName'); 19 | }); 20 | 21 | it('should return the value from enviornment variable', () => { 22 | // arrange 23 | process.env.TABLE_NAME = 'tableNameFromEnv'; 24 | const { config } = require('./config'); 25 | 26 | // act / assert 27 | expect(config.get('tableName')).toEqual('tableNameFromEnv'); 28 | }); 29 | }); 30 | describe('event-bus', () => { 31 | it('should return the default value', () => { 32 | // arrange 33 | const { config } = require('./config'); 34 | // act / assert 35 | expect(config.get('eventBus')).toEqual('eventBus'); 36 | }); 37 | 38 | it('should return the value from enviornment variable', () => { 39 | // arrange 40 | process.env.EVENT_BUS = 'eventBusFromEnv'; 41 | const { config } = require('./config'); 42 | 43 | // act / assert 44 | expect(config.get('eventBus')).toEqual('eventBusFromEnv'); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/config/config.ts: -------------------------------------------------------------------------------- 1 | const convict = require('convict'); 2 | 3 | export const config = convict({ 4 | tableName: { 5 | doc: 'The database table where we store customer accounts', 6 | format: String, 7 | default: 'tableName', 8 | env: 'TABLE_NAME', 9 | }, 10 | eventBus: { 11 | doc: 'The event bus that we publish events to', 12 | format: String, 13 | default: 'eventBus', 14 | env: 'EVENT_BUS', 15 | }, 16 | }).validate({ allowed: 'strict' }); 17 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-account/customer-account.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAddressDto } from '@dto/customer-address'; 2 | import { CustomerPlaylistDto } from '@dto/customer-playlist'; 3 | 4 | export enum SubscriptionType { 5 | Basic = 'Basic', 6 | Upgraded = 'Upgraded', 7 | } 8 | 9 | export enum PaymentStatus { 10 | Valid = 'Valid', 11 | Invalid = 'Invalid', 12 | } 13 | 14 | export type CreateCustomerAccountDto = { 15 | id?: string; 16 | created?: string; 17 | updated?: string; 18 | firstName: string; 19 | surname: string; 20 | subscriptionType: SubscriptionType; 21 | paymentStatus: PaymentStatus; 22 | customerAddress: CustomerAddressDto; 23 | }; 24 | 25 | export type CustomerAccountDto = { 26 | id: string; 27 | created: string; 28 | updated: string; 29 | firstName: string; 30 | surname: string; 31 | subscriptionType: SubscriptionType; 32 | paymentStatus: PaymentStatus; 33 | playlists: CustomerPlaylistDto[]; 34 | customerAddress: CustomerAddressDto; 35 | }; 36 | 37 | export type NewCustomerAccountDto = { 38 | firstName: string; 39 | surname: string; 40 | customerAddress: CustomerAddressDto; 41 | }; 42 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customer-account'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-address/customer-address.ts: -------------------------------------------------------------------------------- 1 | export type CustomerAddressDto = { 2 | addressLineOne: string; 3 | addressLineTwo?: string; 4 | addressLineThree?: string; 5 | addressLineFour?: string; 6 | addressLineFive?: string; 7 | postCode: string; 8 | }; 9 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-address/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customer-address'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-playlist/customer-playlist.ts: -------------------------------------------------------------------------------- 1 | export type CustomerPlaylistDto = { 2 | id: string; 3 | created: string; 4 | updated: string; 5 | playlistName: string; 6 | songIds: string[]; 7 | }; 8 | 9 | export type NewCustomerPlaylistDto = { 10 | playlistName: string; 11 | }; 12 | 13 | export type NewCustomerPlaylistSongDto = { 14 | songId: string; 15 | }; 16 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-playlist/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customer-playlist'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './payment-invalid-error'; 2 | export * from './validation-error'; 3 | export * from './subscription-already-upgraded-error'; 4 | export * from './max-number-of-playlists-error'; 5 | export * from './max-playlist-size-error'; 6 | export * from './resource-not-found'; 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/max-number-of-playlists-error.ts: -------------------------------------------------------------------------------- 1 | export class MaxNumberOfPlaylistsError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'MaxNumberOfPlaylistsError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/max-playlist-size-error.ts: -------------------------------------------------------------------------------- 1 | export class MaxPlaylistSizeError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'MaxPlaylistSizeError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/payment-invalid-error.ts: -------------------------------------------------------------------------------- 1 | export class PaymentInvalidError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'PaymentInvalidError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/playlist-not-found-error.ts: -------------------------------------------------------------------------------- 1 | export class PlaylistNotFoundError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'PlaylistNotFoundError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/resource-not-found.ts: -------------------------------------------------------------------------------- 1 | export class ResourceNotFoundError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'ResourceNotFound'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/subscription-already-upgraded-error.ts: -------------------------------------------------------------------------------- 1 | export class SubscriptionAlreadyUpgradedError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'SubscriptionAlreadyUpgradedError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/errors/validation-error.ts: -------------------------------------------------------------------------------- 1 | export class ValidationError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'ValidationError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/events/customer-account-created.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@schemas/customer-account.schema'; 2 | 3 | export const eventName = 'CustomerAccountCreated'; 4 | export const eventSource = 'com.customer-account-onion'; 5 | export const eventSchema = schema; 6 | export const eventVersion = '1'; 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/events/customer-account-updated.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@schemas/customer-account.schema'; 2 | 3 | export const eventName = 'CustomerAccountUpdated'; 4 | export const eventSource = 'com.customer-account-onion'; 5 | export const eventSchema = schema; 6 | export const eventVersion = '1'; 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/events/customer-account-upgraded.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@schemas/customer-account.schema'; 2 | 3 | export const eventName = 'CustomerAccountUpgraded'; 4 | export const eventSource = 'com.customer-account-onion'; 5 | export const eventSchema = schema; 6 | export const eventVersion = '1'; 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/events/customer-playlist-created.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@schemas/customer-playlist.schema'; 2 | 3 | export const eventName = 'CustomerPlaylistCreated'; 4 | export const eventSource = 'com.customer-account-onion'; 5 | export const eventSchema = schema; 6 | export const eventVersion = '1'; 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/events/song-added-to-playlist.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@schemas/customer-playlist.schema'; 2 | 3 | export const eventName = 'SongAddedToPlaylist'; 4 | export const eventSource = 'com.customer-account-onion'; 5 | export const eventSchema = schema; 6 | export const eventVersion = '1'; 7 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-account.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomerAccountDto, 3 | PaymentStatus, 4 | SubscriptionType, 5 | } from '@dto/customer-account'; 6 | 7 | import { CustomerPlaylistDto } from '@dto/customer-playlist'; 8 | import { schema } from './customer-account.schema'; 9 | import { schemaValidator } from '../../../packages/schema-validator'; 10 | 11 | let playlists: CustomerPlaylistDto[] = [ 12 | { 13 | created: '2022-01-01T00:00:00.000Z', 14 | updated: '2022-01-01T00:00:00.000Z', 15 | songIds: ['123', '234'], 16 | id: 'a59e49ad-8f88-448f-8a15-41d560ad6d70', 17 | playlistName: 'testplaylist', 18 | }, 19 | ]; 20 | 21 | let body: CustomerAccountDto = { 22 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 23 | firstName: 'Lee', 24 | surname: 'Gilmore', 25 | paymentStatus: PaymentStatus.Valid, 26 | subscriptionType: SubscriptionType.Upgraded, 27 | playlists, 28 | created: '2022-01-01T00:00:00.000Z', 29 | updated: '2022-01-01T00:00:00.000Z', 30 | customerAddress: { 31 | addressLineOne: 'line one', 32 | addressLineTwo: 'line two', 33 | addressLineThree: 'line three', 34 | addressLineFour: 'line four', 35 | addressLineFive: 'line five', 36 | postCode: 'ne11bb', 37 | }, 38 | }; 39 | 40 | describe('customer-account-schema', () => { 41 | it('should validate correctly with the correct payload', () => { 42 | expect(() => schemaValidator(schema, body)).not.toThrow(); 43 | }); 44 | 45 | it('should throw an error when there are more than 9 properties', () => { 46 | const badBody = { 47 | ...body, 48 | additionalProp: 'tree', 49 | }; 50 | expect(() => 51 | schemaValidator(schema, badBody) 52 | ).toThrowErrorMatchingInlineSnapshot( 53 | `"[{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/maxProperties\\",\\"keyword\\":\\"maxProperties\\",\\"params\\":{\\"limit\\":9},\\"message\\":\\"must NOT have more than 9 properties\\"}]"` 54 | ); 55 | }); 56 | 57 | it('should throw an error when there are less than 9 properties', () => { 58 | const badBody = {}; 59 | expect(() => 60 | schemaValidator(schema, badBody) 61 | ).toThrowErrorMatchingInlineSnapshot( 62 | `"[{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/minProperties\\",\\"keyword\\":\\"minProperties\\",\\"params\\":{\\"limit\\":9},\\"message\\":\\"must NOT have fewer than 9 properties\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"id\\"},\\"message\\":\\"must have required property 'id'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"firstName\\"},\\"message\\":\\"must have required property 'firstName'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"surname\\"},\\"message\\":\\"must have required property 'surname'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"paymentStatus\\"},\\"message\\":\\"must have required property 'paymentStatus'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"subscriptionType\\"},\\"message\\":\\"must have required property 'subscriptionType'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"playlists\\"},\\"message\\":\\"must have required property 'playlists'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"created\\"},\\"message\\":\\"must have required property 'created'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"updated\\"},\\"message\\":\\"must have required property 'updated'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"customerAddress\\"},\\"message\\":\\"must have required property 'customerAddress'\\"}]"` 63 | ); 64 | }); 65 | 66 | it('should throw an error if id is not valid', () => { 67 | const badBody = { 68 | ...body, 69 | id: 111, // not a string 70 | }; 71 | expect(() => 72 | schemaValidator(schema, badBody) 73 | ).toThrowErrorMatchingInlineSnapshot( 74 | `"[{\\"instancePath\\":\\"/id\\",\\"schemaPath\\":\\"#/properties/id/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 75 | ); 76 | }); 77 | 78 | it('should throw an error if firstName is not valid', () => { 79 | const badBody = { 80 | ...body, 81 | firstName: '!@$%*', // not valid 82 | }; 83 | expect(() => 84 | schemaValidator(schema, badBody) 85 | ).toThrowErrorMatchingInlineSnapshot( 86 | `"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 87 | ); 88 | }); 89 | 90 | it('should throw an error if surname is not valid', () => { 91 | const badBody = { 92 | ...body, 93 | surname: '!@$%*', // not valid 94 | }; 95 | expect(() => 96 | schemaValidator(schema, badBody) 97 | ).toThrowErrorMatchingInlineSnapshot( 98 | `"[{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 99 | ); 100 | }); 101 | 102 | it('should throw an error if paymentStatus is not valid', () => { 103 | const badBody = { 104 | ...body, 105 | paymentStatus: 'Tree', // not valid 106 | }; 107 | expect(() => 108 | schemaValidator(schema, badBody) 109 | ).toThrowErrorMatchingInlineSnapshot( 110 | `"[{\\"instancePath\\":\\"/paymentStatus\\",\\"schemaPath\\":\\"#/properties/paymentStatus/enum\\",\\"keyword\\":\\"enum\\",\\"params\\":{\\"allowedValues\\":[\\"Valid\\",\\"Invalid\\"]},\\"message\\":\\"must be equal to one of the allowed values\\"}]"` 111 | ); 112 | }); 113 | 114 | it('should throw an error if subscriptionType is not valid', () => { 115 | const badBody = { 116 | ...body, 117 | subscriptionType: 'Tree', // not valid 118 | }; 119 | expect(() => 120 | schemaValidator(schema, badBody) 121 | ).toThrowErrorMatchingInlineSnapshot( 122 | `"[{\\"instancePath\\":\\"/subscriptionType\\",\\"schemaPath\\":\\"#/properties/subscriptionType/enum\\",\\"keyword\\":\\"enum\\",\\"params\\":{\\"allowedValues\\":[\\"Basic\\",\\"Upgraded\\"]},\\"message\\":\\"must be equal to one of the allowed values\\"}]"` 123 | ); 124 | }); 125 | 126 | it('should throw an error if created is not valid', () => { 127 | const badBody = { 128 | ...body, 129 | created: 111, // not a string 130 | }; 131 | expect(() => 132 | schemaValidator(schema, badBody) 133 | ).toThrowErrorMatchingInlineSnapshot( 134 | `"[{\\"instancePath\\":\\"/created\\",\\"schemaPath\\":\\"#/properties/created/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 135 | ); 136 | }); 137 | 138 | it('should throw an error if updated is not valid', () => { 139 | const badBody = { 140 | ...body, 141 | updated: 111, // not a string 142 | }; 143 | expect(() => 144 | schemaValidator(schema, badBody) 145 | ).toThrowErrorMatchingInlineSnapshot( 146 | `"[{\\"instancePath\\":\\"/updated\\",\\"schemaPath\\":\\"#/properties/updated/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 147 | ); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-account.schema.ts: -------------------------------------------------------------------------------- 1 | import { schema as addressSchema } from '@schemas/customer-address.schema'; 2 | import { schema as playlistSchema } from '@schemas/customer-playlist.schema'; 3 | 4 | export const schema = { 5 | type: 'object', 6 | required: [ 7 | 'id', 8 | 'firstName', 9 | 'surname', 10 | 'paymentStatus', 11 | 'subscriptionType', 12 | 'playlists', 13 | 'created', 14 | 'updated', 15 | 'customerAddress', 16 | ], 17 | maxProperties: 9, 18 | minProperties: 9, 19 | properties: { 20 | id: { 21 | type: 'string', 22 | }, 23 | firstName: { 24 | type: 'string', 25 | pattern: '^[a-zA-Z]+$', 26 | }, 27 | surname: { 28 | type: 'string', 29 | pattern: '^[a-zA-Z]+$', 30 | }, 31 | paymentStatus: { 32 | type: 'string', 33 | enum: ['Valid', 'Invalid'], 34 | }, 35 | subscriptionType: { 36 | type: 'string', 37 | enum: ['Basic', 'Upgraded'], 38 | }, 39 | created: { 40 | type: 'string', 41 | }, 42 | updated: { 43 | type: 'string', 44 | }, 45 | playlists: { 46 | type: 'array', 47 | items: { 48 | ...playlistSchema, 49 | }, 50 | }, 51 | customerAddress: { 52 | ...addressSchema, 53 | }, 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-address.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAddressDto } from '@dto/customer-address'; 2 | import { schema } from './customer-address.schema'; 3 | import { schemaValidator } from '../../../packages/schema-validator'; 4 | 5 | let body: CustomerAddressDto = { 6 | addressLineOne: 'line one', 7 | addressLineTwo: 'line two', 8 | addressLineThree: 'line three', 9 | addressLineFour: 'line four', 10 | addressLineFive: 'line five', 11 | postCode: 'ne11bb', 12 | }; 13 | 14 | describe('customer-address-schema', () => { 15 | it('should validate correctly with the correct payload', () => { 16 | expect(() => schemaValidator(schema, body)).not.toThrow(); 17 | }); 18 | 19 | it('should throw an error when there are more than 6 properties', () => { 20 | const badBody = { 21 | ...body, 22 | additionalProp: 'tree', 23 | }; 24 | expect(() => 25 | schemaValidator(schema, badBody) 26 | ).toThrowErrorMatchingInlineSnapshot( 27 | `"[{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/maxProperties\\",\\"keyword\\":\\"maxProperties\\",\\"params\\":{\\"limit\\":6},\\"message\\":\\"must NOT have more than 6 properties\\"}]"` 28 | ); 29 | }); 30 | 31 | it('should throw an error when address line one is invalid', () => { 32 | const badBody = { 33 | ...body, 34 | addressLineOne: '±', 35 | }; 36 | expect(() => 37 | schemaValidator(schema, badBody) 38 | ).toThrowErrorMatchingInlineSnapshot( 39 | `"[{\\"instancePath\\":\\"/addressLineOne\\",\\"schemaPath\\":\\"#/properties/addressLineOne/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"` 40 | ); 41 | }); 42 | 43 | it('should throw an error when address line two is invalid', () => { 44 | const badBody = { 45 | ...body, 46 | addressLineTwo: '±', 47 | }; 48 | expect(() => 49 | schemaValidator(schema, badBody) 50 | ).toThrowErrorMatchingInlineSnapshot( 51 | `"[{\\"instancePath\\":\\"/addressLineTwo\\",\\"schemaPath\\":\\"#/properties/addressLineTwo/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"` 52 | ); 53 | }); 54 | 55 | it('should throw an error when address line three is invalid', () => { 56 | const badBody = { 57 | ...body, 58 | addressLineThree: '±', 59 | }; 60 | expect(() => 61 | schemaValidator(schema, badBody) 62 | ).toThrowErrorMatchingInlineSnapshot( 63 | `"[{\\"instancePath\\":\\"/addressLineThree\\",\\"schemaPath\\":\\"#/properties/addressLineThree/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"` 64 | ); 65 | }); 66 | 67 | it('should throw an error when address line four is invalid', () => { 68 | const badBody = { 69 | ...body, 70 | addressLineFour: '±', 71 | }; 72 | expect(() => 73 | schemaValidator(schema, badBody) 74 | ).toThrowErrorMatchingInlineSnapshot( 75 | `"[{\\"instancePath\\":\\"/addressLineFour\\",\\"schemaPath\\":\\"#/properties/addressLineFour/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"` 76 | ); 77 | }); 78 | 79 | it('should throw an error when address line five is invalid', () => { 80 | const badBody = { 81 | ...body, 82 | addressLineFive: '±', 83 | }; 84 | expect(() => 85 | schemaValidator(schema, badBody) 86 | ).toThrowErrorMatchingInlineSnapshot( 87 | `"[{\\"instancePath\\":\\"/addressLineFive\\",\\"schemaPath\\":\\"#/properties/addressLineFive/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"` 88 | ); 89 | }); 90 | 91 | it('should throw an error when postcode is invalid', () => { 92 | const badBody = { 93 | ...body, 94 | postCode: '±', 95 | }; 96 | expect(() => 97 | schemaValidator(schema, badBody) 98 | ).toThrowErrorMatchingInlineSnapshot( 99 | `"[{\\"instancePath\\":\\"/postCode\\",\\"schemaPath\\":\\"#/properties/postCode/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"` 100 | ); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-address.schema.ts: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: 'object', 3 | required: ['addressLineOne', 'postCode'], 4 | maxProperties: 6, 5 | minProperties: 2, 6 | properties: { 7 | addressLineOne: { 8 | type: 'string', 9 | pattern: '^[a-zA-Z0-9 _.-]+$', 10 | }, 11 | addressLineTwo: { 12 | type: 'string', 13 | pattern: '^[a-zA-Z0-9 _.-]+$', 14 | }, 15 | addressLineThree: { 16 | type: 'string', 17 | pattern: '^[a-zA-Z0-9 _.-]+$', 18 | }, 19 | addressLineFour: { 20 | type: 'string', 21 | pattern: '^[a-zA-Z0-9 _.-]+$', 22 | }, 23 | addressLineFive: { 24 | type: 'string', 25 | pattern: '^[a-zA-Z0-9 _.-]+$', 26 | }, 27 | postCode: { 28 | type: 'string', 29 | pattern: '^[a-zA-Z0-9 _.-]+$', 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-playlist.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { CustomerPlaylistDto } from '@dto/customer-playlist'; 2 | import { schema } from './customer-playlist.schema'; 3 | import { schemaValidator } from '../../../packages/schema-validator'; 4 | 5 | let body: CustomerPlaylistDto = { 6 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 7 | created: '2022-01-01T00:00:00.000Z', 8 | updated: '2022-01-01T00:00:00.000Z', 9 | playlistName: 'testplaylist', 10 | songIds: [], 11 | }; 12 | 13 | describe('customer-playlist-schema', () => { 14 | it('should validate correctly with the correct payload', () => { 15 | expect(() => schemaValidator(schema, body)).not.toThrow(); 16 | }); 17 | 18 | it('should throw an error when there are more than 5 properties', () => { 19 | const badBody = { 20 | ...body, 21 | additionalProp: 'tree', 22 | }; 23 | expect(() => 24 | schemaValidator(schema, badBody) 25 | ).toThrowErrorMatchingInlineSnapshot( 26 | `"[{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/maxProperties\\",\\"keyword\\":\\"maxProperties\\",\\"params\\":{\\"limit\\":5},\\"message\\":\\"must NOT have more than 5 properties\\"}]"` 27 | ); 28 | }); 29 | 30 | it('should throw an error when there are less than 5 properties', () => { 31 | const badBody = {}; 32 | expect(() => 33 | schemaValidator(schema, badBody) 34 | ).toThrowErrorMatchingInlineSnapshot( 35 | `"[{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/minProperties\\",\\"keyword\\":\\"minProperties\\",\\"params\\":{\\"limit\\":5},\\"message\\":\\"must NOT have fewer than 5 properties\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"id\\"},\\"message\\":\\"must have required property 'id'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"created\\"},\\"message\\":\\"must have required property 'created'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"updated\\"},\\"message\\":\\"must have required property 'updated'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"playlistName\\"},\\"message\\":\\"must have required property 'playlistName'\\"},{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/required\\",\\"keyword\\":\\"required\\",\\"params\\":{\\"missingProperty\\":\\"songIds\\"},\\"message\\":\\"must have required property 'songIds'\\"}]"` 36 | ); 37 | }); 38 | 39 | it('should throw an error if id is not valid', () => { 40 | const badBody = { 41 | ...body, 42 | id: 111, // not a string 43 | }; 44 | expect(() => 45 | schemaValidator(schema, badBody) 46 | ).toThrowErrorMatchingInlineSnapshot( 47 | `"[{\\"instancePath\\":\\"/id\\",\\"schemaPath\\":\\"#/properties/id/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 48 | ); 49 | }); 50 | 51 | it('should throw an error if playlistName is not valid', () => { 52 | const badBody = { 53 | ...body, 54 | playlistName: '!@$%*', // not valid 55 | }; 56 | expect(() => 57 | schemaValidator(schema, badBody) 58 | ).toThrowErrorMatchingInlineSnapshot( 59 | `"[{\\"instancePath\\":\\"/playlistName\\",\\"schemaPath\\":\\"#/properties/playlistName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 60 | ); 61 | }); 62 | 63 | it('should throw an error if songIds is not valid', () => { 64 | const badBody = { 65 | ...body, 66 | songIds: [1, 2, 3], // not valid 67 | }; 68 | expect(() => 69 | schemaValidator(schema, badBody) 70 | ).toThrowErrorMatchingInlineSnapshot( 71 | `"[{\\"instancePath\\":\\"/songIds/0\\",\\"schemaPath\\":\\"#/properties/songIds/items/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"},{\\"instancePath\\":\\"/songIds/1\\",\\"schemaPath\\":\\"#/properties/songIds/items/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"},{\\"instancePath\\":\\"/songIds/2\\",\\"schemaPath\\":\\"#/properties/songIds/items/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 72 | ); 73 | }); 74 | 75 | it('should throw an error if created is not valid', () => { 76 | const badBody = { 77 | ...body, 78 | created: 111, // not a string 79 | }; 80 | expect(() => 81 | schemaValidator(schema, badBody) 82 | ).toThrowErrorMatchingInlineSnapshot( 83 | `"[{\\"instancePath\\":\\"/created\\",\\"schemaPath\\":\\"#/properties/created/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 84 | ); 85 | }); 86 | 87 | it('should throw an error if updated is not valid', () => { 88 | const badBody = { 89 | ...body, 90 | updated: 111, // not a string 91 | }; 92 | expect(() => 93 | schemaValidator(schema, badBody) 94 | ).toThrowErrorMatchingInlineSnapshot( 95 | `"[{\\"instancePath\\":\\"/updated\\",\\"schemaPath\\":\\"#/properties/updated/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-playlist.schema.ts: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: 'object', 3 | required: ['id', 'created', 'updated', 'playlistName', 'songIds'], 4 | maxProperties: 5, 5 | minProperties: 5, 6 | properties: { 7 | id: { 8 | type: 'string', 9 | }, 10 | created: { 11 | type: 'string', 12 | }, 13 | updated: { 14 | type: 'string', 15 | }, 16 | playlistName: { 17 | type: 'string', 18 | pattern: '^[a-zA-Z]+$', 19 | }, 20 | songIds: { 21 | type: 'array', 22 | items: { 23 | type: 'string', 24 | }, 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/shared/date-utils.ts: -------------------------------------------------------------------------------- 1 | export const getISOString = () => { 2 | return new Date().toISOString(); 3 | }; 4 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './date-utils'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/add-song-to-playlist/add-song-to-playlist.test.ts: -------------------------------------------------------------------------------- 1 | import * as databaseAdapter from '@adapters/secondary/database-adapter/database-adapter'; 2 | import * as publishEvent from '@adapters/secondary/event-adapter/event-adapter'; 3 | 4 | import { 5 | CustomerAccountDto, 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@dto/customer-account'; 9 | 10 | import { NewCustomerPlaylistSongDto } from '@dto/customer-playlist'; 11 | import { addSongToPlaylistUseCase } from '@use-cases/add-song-to-playlist/add-song-to-playlist'; 12 | 13 | let customerAccountDto: CustomerAccountDto; 14 | let newCustomerPlaylistSongDto: NewCustomerPlaylistSongDto; 15 | 16 | let publishEventSpy: jest.SpyInstance; 17 | 18 | describe('add-song-to-playlist-use-case', () => { 19 | beforeAll(() => { 20 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 21 | }); 22 | 23 | afterEach(() => { 24 | jest.restoreAllMocks(); 25 | }); 26 | 27 | beforeEach(() => { 28 | customerAccountDto = { 29 | id: '111', 30 | firstName: 'Lee', 31 | surname: 'Gilmore', 32 | subscriptionType: SubscriptionType.Basic, 33 | paymentStatus: PaymentStatus.Valid, 34 | created: 'created', 35 | updated: 'updated', 36 | playlists: [ 37 | { 38 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 39 | playlistName: 'existingPlaylist', 40 | songIds: [], 41 | created: 'created', 42 | updated: 'updated', 43 | }, 44 | ], 45 | customerAddress: { 46 | addressLineOne: 'line one', 47 | addressLineTwo: 'line two', 48 | addressLineThree: 'line three', 49 | addressLineFour: 'line four', 50 | addressLineFive: 'line five', 51 | postCode: 'ne11bb', 52 | }, 53 | }; 54 | newCustomerPlaylistSongDto = { 55 | songId: 'songone', 56 | }; 57 | 58 | jest 59 | .spyOn(databaseAdapter, 'retrieveAccount') 60 | .mockResolvedValue(customerAccountDto); 61 | 62 | jest 63 | .spyOn(databaseAdapter, 'updateAccount') 64 | .mockResolvedValue(customerAccountDto); 65 | 66 | publishEventSpy = jest 67 | .spyOn(publishEvent, 'publishEvent') 68 | .mockResolvedValue(); 69 | }); 70 | 71 | it('should throw an error if the playlist is not found', async () => { 72 | // act 73 | jest 74 | .spyOn(databaseAdapter, 'retrieveAccount') 75 | .mockResolvedValueOnce(null as any); 76 | 77 | // arrange / assert 78 | await expect( 79 | addSongToPlaylistUseCase( 80 | '111', 81 | 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 82 | newCustomerPlaylistSongDto 83 | ) 84 | ).rejects.toThrowErrorMatchingInlineSnapshot(`"Account 111 not found"`); 85 | }); 86 | 87 | it('should throw an error if the playlist song count is greater than 4', async () => { 88 | // act 89 | customerAccountDto = { 90 | id: '111', 91 | firstName: 'Lee', 92 | surname: 'Gilmore', 93 | subscriptionType: SubscriptionType.Basic, 94 | paymentStatus: PaymentStatus.Valid, 95 | created: 'created', 96 | updated: 'updated', 97 | playlists: [ 98 | { 99 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 100 | playlistName: 'existingPlaylist', 101 | songIds: ['1', '2', '3', '4'], 102 | created: 'created', 103 | updated: 'updated', 104 | }, 105 | ], 106 | customerAddress: { 107 | addressLineOne: 'line one', 108 | addressLineTwo: 'line two', 109 | addressLineThree: 'line three', 110 | addressLineFour: 'line four', 111 | addressLineFive: 'line five', 112 | postCode: 'ne11bb', 113 | }, 114 | }; 115 | 116 | jest 117 | .spyOn(databaseAdapter, 'retrieveAccount') 118 | .mockResolvedValueOnce(customerAccountDto); 119 | 120 | // arrange / assert 121 | await expect( 122 | addSongToPlaylistUseCase( 123 | '111', 124 | 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 125 | newCustomerPlaylistSongDto 126 | ) 127 | ).rejects.toThrowErrorMatchingInlineSnapshot( 128 | `"the maximum playlist length is 4"` 129 | ); 130 | }); 131 | 132 | it('should publish the event with the correct values', async () => { 133 | // act 134 | await addSongToPlaylistUseCase( 135 | '111', 136 | 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 137 | newCustomerPlaylistSongDto 138 | ); 139 | 140 | // arrange / assert 141 | expect(publishEventSpy).toHaveBeenCalledWith( 142 | { 143 | created: 'created', 144 | customerAddress: { 145 | addressLineFive: 'line five', 146 | addressLineFour: 'line four', 147 | addressLineOne: 'line one', 148 | addressLineThree: 'line three', 149 | addressLineTwo: 'line two', 150 | postCode: 'ne11bb', 151 | }, 152 | firstName: 'Lee', 153 | id: '111', 154 | paymentStatus: 'Valid', 155 | playlists: [ 156 | { 157 | created: 'created', 158 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 159 | playlistName: 'existingPlaylist', 160 | songIds: ['songone'], 161 | updated: 'updated', 162 | }, 163 | ], 164 | subscriptionType: 'Basic', 165 | surname: 'Gilmore', 166 | updated: '2022-01-01T00:00:00.000Z', 167 | }, 168 | 'SongAddedToPlaylist', 169 | 'com.customer-account-onion', 170 | '1', 171 | '2022-01-01T00:00:00.000Z' 172 | ); 173 | }); 174 | 175 | it('should return the correct dto on success', async () => { 176 | // act 177 | const response = await addSongToPlaylistUseCase( 178 | '111', 179 | 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 180 | newCustomerPlaylistSongDto 181 | ); 182 | // arrange / assert 183 | expect(response).toMatchInlineSnapshot(` 184 | Object { 185 | "created": "created", 186 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 187 | "playlistName": "existingPlaylist", 188 | "songIds": Array [ 189 | "songone", 190 | ], 191 | "updated": "updated", 192 | } 193 | `); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/add-song-to-playlist/add-song-to-playlist.ts: -------------------------------------------------------------------------------- 1 | import * as songAddedToPlaylistEvent from '@events/song-added-to-playlist'; 2 | 3 | import { 4 | CustomerPlaylistDto, 5 | NewCustomerPlaylistSongDto, 6 | } from '@dto/customer-playlist'; 7 | import { 8 | retrieveAccount, 9 | updateAccount, 10 | } from '@adapters/secondary/database-adapter'; 11 | 12 | import { CustomerAccountDto } from '@dto/customer-account'; 13 | import { MaxPlaylistSizeError } from '@errors/max-playlist-size-error'; 14 | import { PlaylistNotFoundError } from '@errors/playlist-not-found-error'; 15 | import { ResourceNotFoundError } from '@errors/resource-not-found'; 16 | import { getISOString } from '@shared/date-utils'; 17 | import { logger } from '@packages/logger'; 18 | import { publishEvent } from '@adapters/secondary/event-adapter'; 19 | 20 | const getPlaylistById = ( 21 | existingAccount: CustomerAccountDto, 22 | playlistId: string 23 | ): CustomerPlaylistDto => { 24 | const playlist = existingAccount.playlists.find( 25 | (playlist: CustomerPlaylistDto) => playlist.id === playlistId 26 | ); 27 | 28 | if (playlist === undefined) { 29 | throw new PlaylistNotFoundError(`Playlist ${playlistId} not found`); 30 | } 31 | return playlist; 32 | }; 33 | 34 | // primary adapter --> (use case) --> secondary adapter(s) 35 | 36 | /** 37 | * Adds a song to a Customer Playlist 38 | * Input: accountId, playlistId 39 | * Output: CustomerPlaylistDto 40 | * 41 | * Primary course: 42 | * 43 | * 1. Retrieve the customer account 44 | * 2. Add the song to the playlist 45 | * 3. Save the changes as a whole (aggregate root) 46 | * 4. Publish a SongAddedToPlaylist event. 47 | */ 48 | export async function addSongToPlaylistUseCase( 49 | accountId: string, 50 | playlistId: string, 51 | song: NewCustomerPlaylistSongDto 52 | ): Promise { 53 | const updatedDate = getISOString(); 54 | 55 | const existingAccount: CustomerAccountDto = await retrieveAccount(accountId); 56 | 57 | if (!existingAccount) { 58 | throw new ResourceNotFoundError(`Account ${accountId} not found`); 59 | } 60 | 61 | // check the playlist exists before adding the song to it 62 | getPlaylistById(existingAccount, playlistId); 63 | 64 | // add the song to the playlist 65 | existingAccount.playlists 66 | .find((playlist: CustomerPlaylistDto) => playlist.id === playlistId) 67 | ?.songIds.push(song.songId); 68 | 69 | existingAccount.updated = updatedDate; 70 | 71 | if (getPlaylistById(existingAccount, playlistId).songIds.length >= 3) { 72 | throw new MaxPlaylistSizeError('the maximum playlist length is 4'); 73 | } 74 | 75 | // persist the full aggregate root i.e. all entities (we can't update one on its own) 76 | await updateAccount(existingAccount); 77 | 78 | await publishEvent( 79 | existingAccount, 80 | songAddedToPlaylistEvent.eventName, 81 | songAddedToPlaylistEvent.eventSource, 82 | songAddedToPlaylistEvent.eventVersion, 83 | updatedDate 84 | ); 85 | logger.info(`new playlist created event published for ${existingAccount.id}`); 86 | 87 | return existingAccount.playlists.find( 88 | (playlist: CustomerPlaylistDto) => playlist.id === playlistId 89 | ) as CustomerPlaylistDto; 90 | } 91 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/add-song-to-playlist/index.ts: -------------------------------------------------------------------------------- 1 | export * from './add-song-to-playlist'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-account/create-customer-account.test.ts: -------------------------------------------------------------------------------- 1 | import * as createAccount from '@adapters/secondary/database-adapter/database-adapter'; 2 | import * as publishEvent from '@adapters/secondary/event-adapter/event-adapter'; 3 | 4 | import { 5 | CustomerAccountDto, 6 | NewCustomerAccountDto, 7 | PaymentStatus, 8 | SubscriptionType, 9 | } from '@dto/customer-account'; 10 | 11 | import { createCustomerAccountUseCase } from '@use-cases/create-customer-account/create-customer-account'; 12 | 13 | let customerAccountDto: CustomerAccountDto; 14 | let newCustomerAccountDto: NewCustomerAccountDto; 15 | let publishEventSpy: jest.SpyInstance; 16 | 17 | describe('create-customer-use-case', () => { 18 | beforeAll(() => { 19 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 20 | }); 21 | 22 | afterAll(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | beforeEach(() => { 27 | newCustomerAccountDto = { 28 | firstName: 'Lee', 29 | surname: 'Gilmore', 30 | customerAddress: { 31 | addressLineOne: 'line one', 32 | addressLineTwo: 'line two', 33 | addressLineThree: 'line three', 34 | addressLineFour: 'line four', 35 | addressLineFive: 'line five', 36 | postCode: 'ne11bb', 37 | }, 38 | }; 39 | 40 | customerAccountDto = { 41 | id: '111', 42 | firstName: 'Lee', 43 | surname: 'Gilmore', 44 | subscriptionType: SubscriptionType.Basic, 45 | paymentStatus: PaymentStatus.Valid, 46 | created: 'created', 47 | updated: 'updated', 48 | playlists: [], 49 | customerAddress: { 50 | addressLineOne: 'line one', 51 | addressLineTwo: 'line two', 52 | addressLineThree: 'line three', 53 | addressLineFour: 'line four', 54 | addressLineFive: 'line five', 55 | postCode: 'ne11bb', 56 | }, 57 | }; 58 | 59 | jest 60 | .spyOn(createAccount, 'createAccount') 61 | .mockResolvedValue(customerAccountDto); 62 | 63 | publishEventSpy = jest 64 | .spyOn(publishEvent, 'publishEvent') 65 | .mockResolvedValue(); 66 | }); 67 | 68 | it('should throw an error when the new customer dto is invalid', async () => { 69 | expect.assertions(1); 70 | 71 | // arrange 72 | newCustomerAccountDto.firstName = '±'; 73 | 74 | // act / assert 75 | await expect( 76 | createCustomerAccountUseCase(newCustomerAccountDto) 77 | ).rejects.toThrowErrorMatchingInlineSnapshot( 78 | `"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 79 | ); 80 | }); 81 | 82 | it('should publish the event with the correct values', async () => { 83 | // act 84 | await createCustomerAccountUseCase(newCustomerAccountDto); 85 | 86 | // arrange / assert 87 | expect(publishEventSpy).toHaveBeenCalledWith( 88 | { 89 | created: 'created', 90 | customerAddress: { 91 | addressLineFive: 'line five', 92 | addressLineFour: 'line four', 93 | addressLineOne: 'line one', 94 | addressLineThree: 'line three', 95 | addressLineTwo: 'line two', 96 | postCode: 'ne11bb', 97 | }, 98 | firstName: 'Lee', 99 | id: '111', 100 | paymentStatus: 'Valid', 101 | playlists: [], 102 | subscriptionType: 'Basic', 103 | surname: 'Gilmore', 104 | updated: 'updated', 105 | }, 106 | 'CustomerAccountCreated', 107 | 'com.customer-account-onion', 108 | '1', 109 | '2022-01-01T00:00:00.000Z' 110 | ); 111 | }); 112 | 113 | it('should return the correct dto on success', async () => { 114 | // act 115 | const response = await createCustomerAccountUseCase(newCustomerAccountDto); 116 | // arrange / assert 117 | expect(response).toMatchInlineSnapshot(` 118 | Object { 119 | "created": "created", 120 | "customerAddress": Object { 121 | "addressLineFive": "line five", 122 | "addressLineFour": "line four", 123 | "addressLineOne": "line one", 124 | "addressLineThree": "line three", 125 | "addressLineTwo": "line two", 126 | "postCode": "ne11bb", 127 | }, 128 | "firstName": "Lee", 129 | "id": "111", 130 | "paymentStatus": "Valid", 131 | "playlists": Array [], 132 | "subscriptionType": "Basic", 133 | "surname": "Gilmore", 134 | "updated": "updated", 135 | } 136 | `); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-account/create-customer-account.ts: -------------------------------------------------------------------------------- 1 | import * as customerAccountCreatedEvent from '@events/customer-account-created'; 2 | 3 | import { 4 | CustomerAccountDto, 5 | NewCustomerAccountDto, 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@dto/customer-account'; 9 | 10 | import { createAccount } from '@adapters/secondary/database-adapter'; 11 | import { getISOString } from '@shared/date-utils'; 12 | import { logger } from '@packages/logger'; 13 | import { publishEvent } from '@adapters/secondary/event-adapter'; 14 | import { schema } from '@schemas/customer-account.schema'; 15 | import { schemaValidator } from '@packages/schema-validator'; 16 | import { v4 as uuid } from 'uuid'; 17 | 18 | // takes a dto and calls the use case (returning a dto to the primary adapter) 19 | // primary adapter --> (use case) --> secondary adapter(s) 20 | 21 | /** 22 | * Create a new Customer Account 23 | * Input: NewCustomerAccountDto 24 | * Output: CustomerAccountDto 25 | * 26 | * Primary course: 27 | * 28 | * 1. Validate the customer account details 29 | * 2. Create a new customer account 30 | * 3. Publish a CustomerAccountCreated event. 31 | */ 32 | export async function createCustomerAccountUseCase( 33 | newCustomer: NewCustomerAccountDto 34 | ): Promise { 35 | const createdDate = getISOString(); 36 | 37 | const newCustomerAccount: CustomerAccountDto = { 38 | id: uuid(), 39 | created: createdDate, 40 | updated: createdDate, 41 | subscriptionType: SubscriptionType.Basic, 42 | paymentStatus: PaymentStatus.Valid, 43 | playlists: [], 44 | firstName: newCustomer.firstName, 45 | surname: newCustomer.surname, 46 | customerAddress: newCustomer.customerAddress, 47 | }; 48 | 49 | schemaValidator(schema, newCustomerAccount); 50 | logger.debug(`customer account validated for ${newCustomerAccount.id}`); 51 | 52 | const createdAccount = await createAccount(newCustomerAccount); 53 | logger.info(`customer account created for ${createdAccount.id}`); 54 | 55 | await publishEvent( 56 | createdAccount, 57 | customerAccountCreatedEvent.eventName, 58 | customerAccountCreatedEvent.eventSource, 59 | customerAccountCreatedEvent.eventVersion, 60 | createdDate 61 | ); 62 | logger.info( 63 | `customer account created event published for ${createdAccount.id}` 64 | ); 65 | 66 | return createdAccount; 67 | } 68 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-customer-account'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-playlist/create-customer-playlist.test.ts: -------------------------------------------------------------------------------- 1 | import * as databaseAdapter from '@adapters/secondary/database-adapter/database-adapter'; 2 | import * as publishEvent from '@adapters/secondary/event-adapter/event-adapter'; 3 | 4 | import { 5 | CustomerAccountDto, 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@dto/customer-account'; 9 | 10 | import { NewCustomerPlaylistDto } from '@dto/customer-playlist'; 11 | import { createCustomerPlaylistUseCase } from '@use-cases/create-customer-playlist/create-customer-playlist'; 12 | 13 | let customerAccountDto: CustomerAccountDto; 14 | let newPlaylistDto: NewCustomerPlaylistDto; 15 | let publishEventSpy: jest.SpyInstance; 16 | let retrieveAccountSpy: jest.SpyInstance; 17 | 18 | describe('create-customer-playlist-use-case', () => { 19 | beforeAll(() => { 20 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 21 | }); 22 | 23 | afterEach(() => { 24 | jest.restoreAllMocks(); 25 | }); 26 | 27 | beforeEach(() => { 28 | newPlaylistDto = { 29 | playlistName: 'newPlaylist', 30 | }; 31 | 32 | customerAccountDto = { 33 | id: '111', 34 | firstName: 'Lee', 35 | surname: 'Gilmore', 36 | subscriptionType: SubscriptionType.Basic, 37 | paymentStatus: PaymentStatus.Valid, 38 | created: 'created', 39 | updated: 'updated', 40 | playlists: [], 41 | customerAddress: { 42 | addressLineOne: 'line one', 43 | addressLineTwo: 'line two', 44 | addressLineThree: 'line three', 45 | addressLineFour: 'line four', 46 | addressLineFive: 'line five', 47 | postCode: 'ne11bb', 48 | }, 49 | }; 50 | 51 | jest 52 | .spyOn(databaseAdapter, 'updateAccount') 53 | .mockResolvedValue(customerAccountDto); 54 | 55 | retrieveAccountSpy = jest 56 | .spyOn(databaseAdapter, 'retrieveAccount') 57 | .mockResolvedValue(customerAccountDto); 58 | 59 | publishEventSpy = jest 60 | .spyOn(publishEvent, 'publishEvent') 61 | .mockResolvedValue(); 62 | }); 63 | 64 | it('should throw an error when the max number of playlists is reached', async () => { 65 | expect.assertions(1); 66 | 67 | // arrange 68 | const existingCustomerAccount: CustomerAccountDto = { 69 | ...customerAccountDto, 70 | }; 71 | 72 | const newPlaylist = { 73 | ...newPlaylistDto, 74 | created: 'created', 75 | updated: 'updated', 76 | id: '111', 77 | songIds: [], 78 | }; 79 | existingCustomerAccount.playlists.push(newPlaylist); 80 | existingCustomerAccount.playlists.push(newPlaylist); 81 | existingCustomerAccount.playlists.push(newPlaylist); 82 | 83 | retrieveAccountSpy.mockResolvedValueOnce(existingCustomerAccount); 84 | 85 | // act / assert 86 | await expect( 87 | createCustomerPlaylistUseCase('111', newPlaylistDto) 88 | ).rejects.toThrowErrorMatchingInlineSnapshot( 89 | `"maximum number of playlists reached"` 90 | ); 91 | }); 92 | 93 | it('should throw an error when the new playlist is not valid', async () => { 94 | expect.assertions(1); 95 | 96 | // arrange 97 | newPlaylistDto.playlistName = '±'; 98 | 99 | // act / assert 100 | await expect( 101 | createCustomerPlaylistUseCase('111', newPlaylistDto) 102 | ).rejects.toThrowErrorMatchingInlineSnapshot( 103 | `"[{\\"instancePath\\":\\"/playlists/0/playlistName\\",\\"schemaPath\\":\\"#/properties/playlists/items/properties/playlistName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 104 | ); 105 | }); 106 | 107 | it('should publish the event with the correct values', async () => { 108 | // act 109 | await createCustomerPlaylistUseCase('111', newPlaylistDto); 110 | 111 | // arrange / assert 112 | expect(publishEventSpy).toHaveBeenCalledWith( 113 | { 114 | created: 'created', 115 | customerAddress: { 116 | addressLineFive: 'line five', 117 | addressLineFour: 'line four', 118 | addressLineOne: 'line one', 119 | addressLineThree: 'line three', 120 | addressLineTwo: 'line two', 121 | postCode: 'ne11bb', 122 | }, 123 | firstName: 'Lee', 124 | id: '111', 125 | paymentStatus: 'Valid', 126 | playlists: [ 127 | { 128 | created: '2022-01-01T00:00:00.000Z', 129 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 130 | playlistName: 'newPlaylist', 131 | songIds: [], 132 | updated: '2022-01-01T00:00:00.000Z', 133 | }, 134 | ], 135 | subscriptionType: 'Basic', 136 | surname: 'Gilmore', 137 | updated: '2022-01-01T00:00:00.000Z', 138 | }, 139 | 'CustomerPlaylistCreated', 140 | 'com.customer-account-onion', 141 | '1', 142 | '2022-01-01T00:00:00.000Z' 143 | ); 144 | }); 145 | 146 | it('should return the correct dto on success', async () => { 147 | // act 148 | const response = await createCustomerPlaylistUseCase('111', newPlaylistDto); 149 | // arrange / assert 150 | expect(response).toMatchInlineSnapshot(` 151 | Object { 152 | "created": "2022-01-01T00:00:00.000Z", 153 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 154 | "playlistName": "newPlaylist", 155 | "songIds": Array [], 156 | "updated": "2022-01-01T00:00:00.000Z", 157 | } 158 | `); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-playlist/create-customer-playlist.ts: -------------------------------------------------------------------------------- 1 | import * as customerPlaylistCreatedEvent from '@events/customer-playlist-created'; 2 | 3 | import { 4 | CustomerPlaylistDto, 5 | NewCustomerPlaylistDto, 6 | } from '@dto/customer-playlist'; 7 | import { 8 | retrieveAccount, 9 | updateAccount, 10 | } from '@adapters/secondary/database-adapter'; 11 | 12 | import { CustomerAccountDto } from '@dto/customer-account'; 13 | import { MaxNumberOfPlaylistsError } from '@errors/max-number-of-playlists-error'; 14 | import { getISOString } from '@shared/date-utils'; 15 | import { logger } from '@packages/logger'; 16 | import { publishEvent } from '@adapters/secondary/event-adapter'; 17 | import { schema } from '@schemas/customer-account.schema'; 18 | import { schemaValidator } from '@packages/schema-validator'; 19 | import { v4 as uuid } from 'uuid'; 20 | 21 | // primary adapter --> (use case) --> secondary adapter(s) 22 | 23 | /** 24 | * Create a new Customer Playlist 25 | * Input: accountId, playlist 26 | * Output: CustomerPlaylistDto 27 | * 28 | * Primary course: 29 | * 30 | * 1. Retrieve the customer account 31 | * 2. Create and add the new customer playlist to the account 32 | * 3. Save the changes as a whole (aggregate root) 33 | * 4. Publish a CustomerPlaylistCreated event. 34 | */ 35 | export async function createCustomerPlaylistUseCase( 36 | accountId: string, 37 | playlist: NewCustomerPlaylistDto 38 | ): Promise { 39 | const updatedDate = getISOString(); 40 | 41 | const existingAccount: CustomerAccountDto = await retrieveAccount(accountId); 42 | 43 | // validation that we are not at the max number of playlists before adding another 44 | if (existingAccount.playlists.length >= 2) { 45 | throw new MaxNumberOfPlaylistsError('maximum number of playlists reached'); 46 | } 47 | 48 | // create the new playlist 49 | const newPlaylist: CustomerPlaylistDto = { 50 | id: uuid(), 51 | playlistName: playlist.playlistName, 52 | created: updatedDate, 53 | updated: updatedDate, 54 | songIds: [], 55 | }; 56 | 57 | existingAccount.playlists.push(newPlaylist); 58 | existingAccount.updated = updatedDate; 59 | 60 | // validate before saving 61 | schemaValidator(schema, existingAccount); 62 | logger.debug(`customer playlist validated for ${existingAccount.id}`); 63 | 64 | // persist the full aggregate root i.e. both entities (we can't update one on its own) 65 | await updateAccount(existingAccount); 66 | 67 | logger.info( 68 | `customer playlist ${newPlaylist.id} created for ${existingAccount.id}` 69 | ); 70 | 71 | await publishEvent( 72 | existingAccount, 73 | customerPlaylistCreatedEvent.eventName, 74 | customerPlaylistCreatedEvent.eventSource, 75 | customerPlaylistCreatedEvent.eventVersion, 76 | updatedDate 77 | ); 78 | logger.info(`new playlist created event published for ${existingAccount.id}`); 79 | 80 | return newPlaylist; 81 | } 82 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-playlist/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-customer-playlist'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/retrieve-customer-account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './retrieve-customer-account'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/retrieve-customer-account/retrieve-customer-account.test.ts: -------------------------------------------------------------------------------- 1 | import * as retrieveAccount from '@adapters/secondary/database-adapter/database-adapter'; 2 | 3 | import { 4 | CustomerAccountDto, 5 | PaymentStatus, 6 | SubscriptionType, 7 | } from '@dto/customer-account'; 8 | 9 | import { retrieveCustomerAccountUseCase } from '@use-cases/retrieve-customer-account/retrieve-customer-account'; 10 | 11 | let customerAccountDto: CustomerAccountDto; 12 | let retrieveAccountSpy: jest.SpyInstance; 13 | 14 | describe('retrieve-customer-use-case', () => { 15 | beforeAll(() => { 16 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 17 | }); 18 | 19 | afterAll(() => { 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | beforeEach(() => { 24 | customerAccountDto = { 25 | id: '111', 26 | firstName: 'Lee', 27 | surname: 'Gilmore', 28 | subscriptionType: SubscriptionType.Basic, 29 | paymentStatus: PaymentStatus.Valid, 30 | created: 'created', 31 | updated: 'updated', 32 | playlists: [ 33 | { 34 | id: '2222', 35 | songIds: ['111', '222'], 36 | playlistName: 'playlistOne', 37 | created: 'created', 38 | updated: 'updated', 39 | }, 40 | ], 41 | customerAddress: { 42 | addressLineOne: 'line one', 43 | addressLineTwo: 'line two', 44 | addressLineThree: 'line three', 45 | addressLineFour: 'line four', 46 | addressLineFive: 'line five', 47 | postCode: 'ne11bb', 48 | }, 49 | }; 50 | 51 | retrieveAccountSpy = jest 52 | .spyOn(retrieveAccount, 'retrieveAccount') 53 | .mockResolvedValue(customerAccountDto); 54 | }); 55 | 56 | it('should return an error if the customer account is not valid', async () => { 57 | expect.assertions(1); 58 | 59 | // arrange 60 | customerAccountDto.firstName = '±'; 61 | retrieveAccountSpy.mockResolvedValueOnce(customerAccountDto); 62 | 63 | // act / assert 64 | await expect( 65 | retrieveCustomerAccountUseCase('111')). 66 | rejects.toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"`); 67 | }); 68 | 69 | it('should return the correct dto on success', async () => { 70 | // act 71 | const response = await retrieveCustomerAccountUseCase('111'); 72 | // arrange / assert 73 | expect(response).toMatchInlineSnapshot(` 74 | Object { 75 | "created": "created", 76 | "customerAddress": Object { 77 | "addressLineFive": "line five", 78 | "addressLineFour": "line four", 79 | "addressLineOne": "line one", 80 | "addressLineThree": "line three", 81 | "addressLineTwo": "line two", 82 | "postCode": "ne11bb", 83 | }, 84 | "firstName": "Lee", 85 | "id": "111", 86 | "paymentStatus": "Valid", 87 | "playlists": Array [ 88 | Object { 89 | "created": "created", 90 | "id": "2222", 91 | "playlistName": "playlistOne", 92 | "songIds": Array [ 93 | "111", 94 | "222", 95 | ], 96 | "updated": "updated", 97 | }, 98 | ], 99 | "subscriptionType": "Basic", 100 | "surname": "Gilmore", 101 | "updated": "updated", 102 | } 103 | `); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/retrieve-customer-account/retrieve-customer-account.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAccountDto } from '@dto/customer-account'; 2 | import { logger } from '@packages/logger'; 3 | import { retrieveAccount } from '@adapters/secondary/database-adapter'; 4 | import { schema } from '@schemas/customer-account.schema'; 5 | import { schemaValidator } from '@packages/schema-validator'; 6 | 7 | // primary adapter --> (use case) --> secondary adapter(s) 8 | 9 | /** 10 | * Retrive a Customer Account 11 | * Input: Customer account ID 12 | * Output: CustomerAccountDto 13 | * 14 | * Primary course: 15 | * 16 | * 1.Retrieve the customer account based on ID 17 | */ 18 | export async function retrieveCustomerAccountUseCase( 19 | id: string 20 | ): Promise { 21 | const customerAccount: CustomerAccountDto = await retrieveAccount(id); 22 | 23 | logger.info(`retrieved customer account for ${id}`); 24 | 25 | schemaValidator(schema, customerAccount); 26 | logger.debug(`customer account validated for ${customerAccount.id}`); 27 | 28 | return customerAccount; 29 | } 30 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/upgrade-customer-account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './upgrade-customer-account'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/upgrade-customer-account/upgrade-customer-account.test.ts: -------------------------------------------------------------------------------- 1 | import * as publishEvent from '@adapters/secondary/event-adapter/event-adapter'; 2 | import * as retrieveAccount from '@adapters/secondary/database-adapter/database-adapter'; 3 | 4 | import { 5 | CustomerAccountDto, 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@dto/customer-account'; 9 | 10 | import { upgradeCustomerAccountUseCase } from '@use-cases/upgrade-customer-account/upgrade-customer-account'; 11 | 12 | let customerAccountDto: CustomerAccountDto; 13 | let publishEventSpy: jest.SpyInstance; 14 | 15 | describe('upgrade-customer-use-case', () => { 16 | beforeAll(() => { 17 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 18 | }); 19 | 20 | afterAll(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | beforeEach(() => { 25 | customerAccountDto = { 26 | id: '111', 27 | firstName: 'Lee', 28 | surname: 'Gilmore', 29 | subscriptionType: SubscriptionType.Basic, 30 | paymentStatus: PaymentStatus.Valid, 31 | created: 'created', 32 | updated: 'updated', 33 | playlists: [], 34 | customerAddress: { 35 | addressLineOne: 'line one', 36 | addressLineTwo: 'line two', 37 | addressLineThree: 'line three', 38 | addressLineFour: 'line four', 39 | addressLineFive: 'line five', 40 | postCode: 'ne11bb', 41 | }, 42 | }; 43 | 44 | jest 45 | .spyOn(retrieveAccount, 'retrieveAccount') 46 | .mockResolvedValue(customerAccountDto); 47 | 48 | publishEventSpy = jest 49 | .spyOn(publishEvent, 'publishEvent') 50 | .mockResolvedValue(); 51 | }); 52 | 53 | it('should throw an error when the payment status is invalid', async () => { 54 | expect.assertions(1); 55 | 56 | // arrange 57 | customerAccountDto.paymentStatus = PaymentStatus.Invalid; 58 | 59 | // act / assert 60 | await expect( 61 | upgradeCustomerAccountUseCase('111') 62 | ).rejects.toThrowErrorMatchingInlineSnapshot( 63 | `"Payment is invalid - unable to upgrade"` 64 | ); 65 | }); 66 | 67 | it('should throw an error when the subscription type is already upgraded', async () => { 68 | expect.assertions(1); 69 | 70 | // arrange 71 | customerAccountDto.subscriptionType = SubscriptionType.Upgraded; 72 | 73 | // act / assert 74 | await expect( 75 | upgradeCustomerAccountUseCase('111') 76 | ).rejects.toThrowErrorMatchingInlineSnapshot( 77 | `"Subscription is already upgraded - unable to upgrade"` 78 | ); 79 | }); 80 | 81 | it('should publish the event with the correct values', async () => { 82 | // act 83 | await upgradeCustomerAccountUseCase('111'); 84 | 85 | // arrange / assert 86 | expect(publishEventSpy).toHaveBeenCalledWith( 87 | { 88 | created: 'created', 89 | customerAddress: { 90 | addressLineFive: 'line five', 91 | addressLineFour: 'line four', 92 | addressLineOne: 'line one', 93 | addressLineThree: 'line three', 94 | addressLineTwo: 'line two', 95 | postCode: 'ne11bb', 96 | }, 97 | firstName: 'Lee', 98 | id: '111', 99 | paymentStatus: 'Valid', 100 | playlists: [], 101 | subscriptionType: 'Upgraded', 102 | surname: 'Gilmore', 103 | updated: '2022-01-01T00:00:00.000Z', 104 | }, 105 | 'CustomerAccountUpgraded', 106 | 'com.customer-account-onion', 107 | '1', 108 | '2022-01-01T00:00:00.000Z' 109 | ); 110 | }); 111 | 112 | it('should return the correct dto on success', async () => { 113 | // act 114 | const response = await upgradeCustomerAccountUseCase('111'); 115 | // arrange / assert 116 | expect(response).toMatchInlineSnapshot(` 117 | Object { 118 | "created": "created", 119 | "customerAddress": Object { 120 | "addressLineFive": "line five", 121 | "addressLineFour": "line four", 122 | "addressLineOne": "line one", 123 | "addressLineThree": "line three", 124 | "addressLineTwo": "line two", 125 | "postCode": "ne11bb", 126 | }, 127 | "firstName": "Lee", 128 | "id": "111", 129 | "paymentStatus": "Valid", 130 | "playlists": Array [], 131 | "subscriptionType": "Upgraded", 132 | "surname": "Gilmore", 133 | "updated": "2022-01-01T00:00:00.000Z", 134 | } 135 | `); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/upgrade-customer-account/upgrade-customer-account.ts: -------------------------------------------------------------------------------- 1 | import * as customerAccountUpgradedEvent from '@events/customer-account-upgraded'; 2 | 3 | import { 4 | CustomerAccountDto, 5 | PaymentStatus, 6 | SubscriptionType, 7 | } from '@dto/customer-account'; 8 | import { 9 | retrieveAccount, 10 | updateAccount, 11 | } from '@adapters/secondary/database-adapter'; 12 | 13 | import { PaymentInvalidError } from '@errors/payment-invalid-error'; 14 | import { SubscriptionAlreadyUpgradedError } from '@errors/subscription-already-upgraded-error'; 15 | import { getISOString } from '@shared/date-utils'; 16 | import { logger } from '@packages/logger'; 17 | import { publishEvent } from '@adapters/secondary/event-adapter'; 18 | import { schema } from '@schemas/customer-account.schema'; 19 | import { schemaValidator } from '@packages/schema-validator'; 20 | 21 | // primary adapter --> (use case) --> secondary adapter(s) 22 | 23 | /** 24 | * Upgrade an existing Customer Account 25 | * Input: Customer account ID 26 | * Output: CustomerAccountDto 27 | * 28 | * Primary course: 29 | * 30 | * 1. Retrieve the customer account based on ID 31 | * 2. Upgrade and validate the customer account 32 | * 3. Publish a CustomerAccountUpdated event. 33 | */ 34 | export async function upgradeCustomerAccountUseCase( 35 | id: string 36 | ): Promise { 37 | const updatedDate = getISOString(); 38 | 39 | const customerAccount: CustomerAccountDto = await retrieveAccount(id); 40 | 41 | if (customerAccount.paymentStatus === PaymentStatus.Invalid) { 42 | throw new PaymentInvalidError('Payment is invalid - unable to upgrade'); 43 | } 44 | 45 | // we can not upgrade an account which is already upgraded 46 | if (customerAccount.subscriptionType === SubscriptionType.Upgraded) { 47 | throw new SubscriptionAlreadyUpgradedError( 48 | 'Subscription is already upgraded - unable to upgrade' 49 | ); 50 | } 51 | 52 | // upgrade the account 53 | customerAccount.subscriptionType = SubscriptionType.Upgraded; 54 | customerAccount.updated = updatedDate; 55 | 56 | // validate the account before saving it so it is always valid 57 | schemaValidator(schema, customerAccount); 58 | logger.debug(`customer account validated for ${customerAccount.id}`); 59 | 60 | await updateAccount(customerAccount); 61 | logger.info(`customer account ${id} upgraded`); 62 | 63 | await publishEvent( 64 | customerAccount, 65 | customerAccountUpgradedEvent.eventName, 66 | customerAccountUpgradedEvent.eventSource, 67 | customerAccountUpgradedEvent.eventVersion, 68 | updatedDate 69 | ); 70 | logger.info( 71 | `customer account upgraded event published for ${customerAccount.id}` 72 | ); 73 | 74 | return customerAccount; 75 | } 76 | -------------------------------------------------------------------------------- /onion-sounds/stateless/stateless.ts: -------------------------------------------------------------------------------- 1 | import * as apigw from 'aws-cdk-lib/aws-apigateway'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 6 | import * as nodeLambda from 'aws-cdk-lib/aws-lambda-nodejs'; 7 | import * as path from 'path'; 8 | 9 | import { Construct } from 'constructs'; 10 | import { Tracing } from 'aws-cdk-lib/aws-lambda'; 11 | 12 | export interface ConsumerProps extends cdk.StackProps { 13 | accountsTable: dynamodb.Table; 14 | accountsEventBus: events.EventBus; 15 | } 16 | 17 | export class OnionSoundsStatelessStack extends cdk.Stack { 18 | private readonly accountsTable: dynamodb.Table; 19 | private readonly accountsEventBus: events.EventBus; 20 | 21 | constructor(scope: Construct, id: string, props: ConsumerProps) { 22 | super(scope, id, props); 23 | 24 | this.accountsTable = props.accountsTable; 25 | this.accountsEventBus = props.accountsEventBus; 26 | 27 | const lambdaPowerToolsConfig = { 28 | LOG_LEVEL: 'DEBUG', 29 | POWERTOOLS_LOGGER_LOG_EVENT: 'true', 30 | POWERTOOLS_LOGGER_SAMPLE_RATE: '1', 31 | POWERTOOLS_TRACE_ENABLED: 'enabled', 32 | POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'captureHTTPsRequests', 33 | POWERTOOLS_SERVICE_NAME: 'CustomerService', 34 | POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'captureResult', 35 | POWERTOOLS_METRICS_NAMESPACE: 'OnionSounds', 36 | }; 37 | 38 | const createAccountLambda: nodeLambda.NodejsFunction = 39 | new nodeLambda.NodejsFunction(this, 'CreateAccountLambda', { 40 | runtime: lambda.Runtime.NODEJS_16_X, 41 | entry: path.join( 42 | __dirname, 43 | 'src/adapters/primary/create-customer-account/create-customer-account.adapter.ts' 44 | ), 45 | memorySize: 1024, 46 | handler: 'handler', 47 | tracing: Tracing.ACTIVE, 48 | bundling: { 49 | minify: true, 50 | externalModules: ['aws-sdk'], 51 | }, 52 | environment: { 53 | TABLE_NAME: this.accountsTable.tableName, 54 | EVENT_BUS: this.accountsEventBus.eventBusArn, 55 | ...lambdaPowerToolsConfig, 56 | }, 57 | }); 58 | 59 | const retrieveAccountLambda: nodeLambda.NodejsFunction = 60 | new nodeLambda.NodejsFunction(this, 'RetrieveAccountLambda', { 61 | runtime: lambda.Runtime.NODEJS_16_X, 62 | entry: path.join( 63 | __dirname, 64 | 'src/adapters/primary/retrieve-customer-account/retrieve-customer-account.adapter.ts' 65 | ), 66 | memorySize: 1024, 67 | tracing: Tracing.ACTIVE, 68 | handler: 'handler', 69 | bundling: { 70 | minify: true, 71 | externalModules: ['aws-sdk'], 72 | }, 73 | environment: { 74 | TABLE_NAME: this.accountsTable.tableName, 75 | EVENT_BUS: this.accountsEventBus.eventBusArn, 76 | ...lambdaPowerToolsConfig, 77 | }, 78 | }); 79 | 80 | const upgradeAccountLambda: nodeLambda.NodejsFunction = 81 | new nodeLambda.NodejsFunction(this, 'UpgradeAccountLambda', { 82 | runtime: lambda.Runtime.NODEJS_16_X, 83 | entry: path.join( 84 | __dirname, 85 | 'src/adapters/primary/upgrade-customer-account/upgrade-customer-account.adapter.ts' 86 | ), 87 | memorySize: 1024, 88 | tracing: Tracing.ACTIVE, 89 | handler: 'handler', 90 | bundling: { 91 | minify: true, 92 | externalModules: ['aws-sdk'], 93 | }, 94 | environment: { 95 | TABLE_NAME: this.accountsTable.tableName, 96 | EVENT_BUS: this.accountsEventBus.eventBusArn, 97 | ...lambdaPowerToolsConfig, 98 | }, 99 | }); 100 | 101 | const createPlaylistLambda: nodeLambda.NodejsFunction = 102 | new nodeLambda.NodejsFunction(this, 'CreatePlaylistLambda', { 103 | runtime: lambda.Runtime.NODEJS_16_X, 104 | entry: path.join( 105 | __dirname, 106 | 'src/adapters/primary/create-customer-playlist/create-customer-playlist.adpater.ts' 107 | ), 108 | memorySize: 1024, 109 | handler: 'handler', 110 | tracing: Tracing.ACTIVE, 111 | bundling: { 112 | minify: true, 113 | externalModules: ['aws-sdk'], 114 | }, 115 | environment: { 116 | TABLE_NAME: this.accountsTable.tableName, 117 | EVENT_BUS: this.accountsEventBus.eventBusArn, 118 | ...lambdaPowerToolsConfig, 119 | }, 120 | }); 121 | 122 | const addSongToPlaylistLambda: nodeLambda.NodejsFunction = 123 | new nodeLambda.NodejsFunction(this, 'AddSongToPlaylistLambda', { 124 | runtime: lambda.Runtime.NODEJS_16_X, 125 | entry: path.join( 126 | __dirname, 127 | 'src/adapters/primary/add-song-to-playlist/add-song-to-playlist.adapter.ts' 128 | ), 129 | memorySize: 1024, 130 | handler: 'handler', 131 | tracing: Tracing.ACTIVE, 132 | bundling: { 133 | minify: true, 134 | externalModules: ['aws-sdk'], 135 | }, 136 | environment: { 137 | TABLE_NAME: this.accountsTable.tableName, 138 | EVENT_BUS: this.accountsEventBus.eventBusArn, 139 | ...lambdaPowerToolsConfig, 140 | }, 141 | }); 142 | 143 | this.accountsTable.grantWriteData(createAccountLambda); 144 | this.accountsTable.grantReadData(retrieveAccountLambda); 145 | this.accountsTable.grantReadWriteData(upgradeAccountLambda); 146 | this.accountsTable.grantReadWriteData(createPlaylistLambda); 147 | this.accountsTable.grantReadWriteData(addSongToPlaylistLambda); 148 | 149 | this.accountsEventBus.grantPutEventsTo(createAccountLambda); 150 | this.accountsEventBus.grantPutEventsTo(upgradeAccountLambda); 151 | this.accountsEventBus.grantPutEventsTo(createPlaylistLambda); 152 | this.accountsEventBus.grantPutEventsTo(addSongToPlaylistLambda); 153 | 154 | const accountsApi: apigw.RestApi = new apigw.RestApi(this, 'AccountsApi', { 155 | description: 'Onion Accounts API', 156 | deploy: true, 157 | deployOptions: { 158 | stageName: 'prod', 159 | loggingLevel: apigw.MethodLoggingLevel.INFO, 160 | }, 161 | }); 162 | 163 | const accounts: apigw.Resource = accountsApi.root.addResource('accounts'); 164 | const account: apigw.Resource = accounts.addResource('{id}'); 165 | const playlists: apigw.Resource = account.addResource('playlists'); 166 | const playlist: apigw.Resource = playlists.addResource('{playlistId}'); 167 | 168 | playlist.addMethod( 169 | 'POST', 170 | new apigw.LambdaIntegration(addSongToPlaylistLambda, { 171 | proxy: true, 172 | }) 173 | ); 174 | 175 | accounts.addMethod( 176 | 'POST', 177 | new apigw.LambdaIntegration(createAccountLambda, { 178 | proxy: true, 179 | }) 180 | ); 181 | 182 | playlists.addMethod( 183 | 'POST', 184 | new apigw.LambdaIntegration(createPlaylistLambda, { 185 | proxy: true, 186 | }) 187 | ); 188 | 189 | account.addMethod( 190 | 'GET', 191 | new apigw.LambdaIntegration(retrieveAccountLambda, { 192 | proxy: true, 193 | }) 194 | ); 195 | 196 | account.addMethod( 197 | 'PATCH', 198 | new apigw.LambdaIntegration(upgradeAccountLambda, { 199 | proxy: true, 200 | }) 201 | ); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /onion-sounds/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018", "DOM"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "esModuleInterop": true, 14 | "noUnusedParameters": false, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": false, 17 | "inlineSourceMap": true, 18 | "inlineSources": true, 19 | "experimentalDecorators": true, 20 | "strictPropertyInitialization": false, 21 | "typeRoots": ["./node_modules/@types"], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@adapters/*": ["./stateless/src/adapters/*"], 25 | "@config/*": ["./stateless/src/config/*"], 26 | "@domain/*": ["./stateless/src/domain/*"], 27 | "@entity/*": ["./stateless/src/entity/*"], 28 | "@errors/*": ["./stateless/src/errors/*"], 29 | "@schemas/*": ["./stateless/src/schemas/*"], 30 | "@shared/*": ["./stateless/src/shared/*"], 31 | "@models/*": ["stateless/src/models/*"], 32 | "@dto/*": ["stateless/src/dto/*"], 33 | "@use-cases/*": ["./stateless/src/use-cases/*"], 34 | "@packages/*": ["./packages/*"], 35 | "@events/*": ["stateless/src/events/*"] 36 | } 37 | }, 38 | "exclude": ["node_modules", "cdk.out"] 39 | } 40 | -------------------------------------------------------------------------------- /onion-sounds/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "./stateless/src/use-cases/create-customer-account/create-customer-account.ts", 4 | "./stateless/src/use-cases/retrieve-customer-account/retrieve-customer-account.ts", 5 | "./stateless/src/use-cases/upgrade-customer-account/upgrade-customer-account.ts", 6 | "./stateless/src/use-cases/create-customer-playlist/create-customer-playlist.ts", 7 | "./stateless/src/models/customer-account-types.ts", 8 | "./stateless/src/models/customer-playlist-types.ts", 9 | "./stateless/src/models/customer-address-types.ts", 10 | "./stateless/src/domain/customer-account/customer-account.ts", 11 | "./stateless/src/domain/customer-address/customer-address.ts", 12 | "./stateless/src/domain/customer-playlist/customer-playlist.ts", 13 | "./stateless/src/errors/index.ts", 14 | "./stateless/src/events/customer-account-created.ts", 15 | "./stateless/src/events/customer-account-upgraded.ts", 16 | "./stateless/src/events/customer-playlist-created.ts", 17 | "./stateless/src/dto/customer-account/customer-account.ts", 18 | "./stateless/src/dto/customer-playlist/customer-playlist.ts", 19 | "./stateless/src/dto/customer-address/customer-address.ts", 20 | "./stateless/src/entity/domain-event.ts" 21 | ], 22 | "out": "../docs/documentation", 23 | "theme": "default", 24 | "media": "../docs/images/", 25 | "name": "Onion Sounds - Customer Account Domain Service", 26 | "includeVersion": true, 27 | "lightHighlightTheme": "light-plus", 28 | "hideGenerator": true, 29 | "exclude": ["**/*+(index|.test|.spec|.e2e).ts"], 30 | "readme": "../docs/DOCS.md", 31 | "disableSources": true, 32 | "excludePrivate": true, 33 | "excludeProtected": true 34 | } 35 | -------------------------------------------------------------------------------- /open-api/customer-accounts-open-api-v1.yml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.1" 2 | info: 3 | title: "AccountsApi" 4 | description: "Onion Accounts API" 5 | version: 1.0.0 6 | servers: 7 | - url: "https://your-rest-api.execute-api.your-region.amazonaws.com/{basePath}" 8 | variables: 9 | basePath: 10 | default: "/prod" 11 | paths: 12 | /accounts/{id}: 13 | get: 14 | parameters: 15 | - name: "id" 16 | in: "path" 17 | required: true 18 | schema: 19 | type: "string" 20 | responses: 21 | '200': 22 | description: get a customer account 23 | content: 24 | application/json: 25 | schema: 26 | $ref: '#/components/schemas/CustomerAccount' 27 | patch: 28 | parameters: 29 | - name: "id" 30 | in: "path" 31 | required: true 32 | schema: 33 | type: "string" 34 | responses: 35 | '200': 36 | description: upgrade a customer account 37 | content: 38 | application/json: 39 | schema: 40 | $ref: '#/components/schemas/CustomerAccount' 41 | /accounts: 42 | post: 43 | requestBody: 44 | description: create a new customer account 45 | content: 46 | 'application/json': 47 | schema: 48 | $ref: '#/components/schemas/CreateNewCustomerAccount' 49 | responses: 50 | '201': 51 | description: create a customer account 52 | content: 53 | application/json: 54 | schema: 55 | $ref: '#/components/schemas/CustomerAccount' 56 | /accounts/{id}/playlists/: 57 | post: 58 | requestBody: 59 | description: create a new customer playlist 60 | content: 61 | 'application/json': 62 | schema: 63 | $ref: '#/components/schemas/CreateNewPlaylist' 64 | responses: 65 | '201': 66 | description: create a customer playlist 67 | content: 68 | application/json: 69 | schema: 70 | $ref: '#/components/schemas/CustomerPlaylist' 71 | /accounts/{id}/playlists/{playlistId}/: 72 | post: 73 | requestBody: 74 | description: add a song to the playlist 75 | content: 76 | 'application/json': 77 | schema: 78 | $ref: '#/components/schemas/AddSongToPlaylist' 79 | responses: 80 | '201': 81 | description: add a song to the playlist 82 | content: 83 | application/json: 84 | schema: 85 | $ref: '#/components/schemas/CustomerPlaylist' 86 | components: 87 | schemas: 88 | CreateNewCustomerAccount: 89 | type: object 90 | required: 91 | - firstName 92 | - surname 93 | properties: 94 | firstname: 95 | type: "string" 96 | surname: 97 | type: "string" 98 | customerAddress: 99 | type: "object" 100 | $ref: '#/components/schemas/CustomerAddress' 101 | CustomerAccount: 102 | type: object 103 | required: 104 | - firstName 105 | - surname 106 | - created 107 | - updated 108 | - subscriptionType 109 | - paymentStatus 110 | - playlists 111 | - customerAddress 112 | properties: 113 | firstname: 114 | type: "string" 115 | surname: 116 | type: "string" 117 | created: 118 | type: "string" 119 | updated: 120 | type: "string" 121 | subscriptionType: 122 | type: "string" 123 | paymentStatus: 124 | type: "string" 125 | playlists: 126 | type: "array" 127 | items: 128 | $ref: '#/components/schemas/CustomerPlaylist' 129 | customerAddress: 130 | $ref: '#/components/schemas/CustomerPlaylist' 131 | CreateNewPlaylist: 132 | type: object 133 | required: 134 | - playlistName 135 | properties: 136 | playlistName: 137 | type: "string" 138 | AddSongToPlaylist: 139 | type: object 140 | required: 141 | - songId 142 | properties: 143 | songId: 144 | type: "string" 145 | CustomerAddress: 146 | type: object 147 | required: 148 | - addressLineOne 149 | - postCode 150 | properties: 151 | addressLineOne: 152 | type: "string" 153 | addressLineTwo: 154 | type: "string" 155 | addressLineThree: 156 | type: "string" 157 | addressLineFour: 158 | type: "string" 159 | addressLineFive: 160 | type: "string" 161 | postCode: 162 | type: "string" 163 | CustomerPlaylist: 164 | type: object 165 | required: 166 | - playlistName 167 | - id 168 | - created 169 | - updated 170 | - songIds 171 | properties: 172 | id: 173 | type: "string" 174 | created: 175 | type: "string" 176 | updated: 177 | type: "string" 178 | playlistName: 179 | type: "string" 180 | songIds: 181 | type: "array" 182 | items: 183 | type: 'string' 184 | -------------------------------------------------------------------------------- /postman/Serverless Clean Architecture.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "3379d34b-fac5-4993-a6d0-e4f1eddf1c14", 4 | "name": "Serverless Clean Architecture", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "752706" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Create Account", 11 | "request": { 12 | "method": "POST", 13 | "header": [], 14 | "body": { 15 | "mode": "raw", 16 | "raw": "{\n \"firstName\": \"Lee\",\n \"surname\": \"Gilmore\",\n \"customerAddress\": {\n \"addressLineOne\": \"12 Bob Street\",\n \"postCode\": \"ne91bb\"\n }\n}", 17 | "options": { 18 | "raw": { 19 | "language": "json" 20 | } 21 | } 22 | }, 23 | "url": { 24 | "raw": "{{api-url}}/prod/accounts/", 25 | "host": ["{{api-url}}"], 26 | "path": ["prod", "accounts", ""] 27 | } 28 | }, 29 | "response": [] 30 | }, 31 | { 32 | "name": "Upgrade Account", 33 | "request": { 34 | "method": "PATCH", 35 | "header": [], 36 | "body": { 37 | "mode": "raw", 38 | "raw": "{\n \"firstName\": \"Lee\",\n \"surname\": \"Gilmore\",\n \"customerAddress\": {\n \"addressLineOne\": \"12 Next Street\",\n \"postCode\": \"ne91bb\"\n }\n}", 39 | "options": { 40 | "raw": { 41 | "language": "json" 42 | } 43 | } 44 | }, 45 | "url": { 46 | "raw": "{{api-url}}/prod/accounts/5f00d811-f507-44c2-bf3a-36591845e9d2", 47 | "host": ["{{api-url}}"], 48 | "path": ["prod", "accounts", "5f00d811-f507-44c2-bf3a-36591845e9d2"] 49 | } 50 | }, 51 | "response": [] 52 | }, 53 | { 54 | "name": "Create Playlist", 55 | "request": { 56 | "method": "POST", 57 | "header": [], 58 | "body": { 59 | "mode": "raw", 60 | "raw": "{\n \"playlistName\": \"coolmusic\"\n}", 61 | "options": { 62 | "raw": { 63 | "language": "json" 64 | } 65 | } 66 | }, 67 | "url": { 68 | "raw": "{{api-url}}/prod/accounts/7d4bb2be-c6c8-4bfb-9478-1b0f4e09f5e4/playlists", 69 | "protocol": "https", 70 | "host": ["{{api-url}}"], 71 | "path": [ 72 | "prod", 73 | "accounts", 74 | "7d4bb2be-c6c8-4bfb-9478-1b0f4e09f5e4", 75 | "playlists" 76 | ] 77 | } 78 | }, 79 | "response": [] 80 | }, 81 | { 82 | "name": "Add Song To Playlist", 83 | "request": { 84 | "method": "POST", 85 | "header": [], 86 | "body": { 87 | "mode": "raw", 88 | "raw": "{\n \"songId\": \"four\"\n}", 89 | "options": { 90 | "raw": { 91 | "language": "json" 92 | } 93 | } 94 | }, 95 | "url": { 96 | "raw": "{{api-url}}/prod/accounts/5f00d811-f507-44c2-bf3a-36591845e9d2/playlists/a411ad08-7c63-4153-bad5-22ff63184664/", 97 | "host": ["{{api-url}}"], 98 | "path": [ 99 | "prod", 100 | "accounts", 101 | "5f00d811-f507-44c2-bf3a-36591845e9d2", 102 | "playlists", 103 | "a411ad08-7c63-4153-bad5-22ff63184664", 104 | "" 105 | ] 106 | } 107 | }, 108 | "response": [] 109 | }, 110 | { 111 | "name": "Get Account by ID", 112 | "request": { 113 | "method": "GET", 114 | "header": [], 115 | "url": { 116 | "raw": "{{api-url}}/prod/accounts/5f00d811-f507-44c2-bf3a-36591845e9d2", 117 | "host": ["{{api-url}}"], 118 | "path": ["prod", "accounts", "5f00d811-f507-44c2-bf3a-36591845e9d2"] 119 | } 120 | }, 121 | "response": [] 122 | }, 123 | { 124 | "name": "Upgrade Account", 125 | "request": { 126 | "method": "PATCH", 127 | "header": [], 128 | "url": { 129 | "raw": "{{api-url}}/prod/accounts/0af07a11-6477-47bd-99b6-a73f694beada", 130 | "protocol": "https", 131 | "host": ["{{api-url}}"], 132 | "path": ["prod", "accounts", "0af07a11-6477-47bd-99b6-a73f694beada"] 133 | } 134 | }, 135 | "response": [] 136 | } 137 | ], 138 | "variable": [ 139 | { 140 | "key": "api-url", 141 | "value": "https://your-api.execute-api.eu-west-1.amazonaws.com" 142 | } 143 | ] 144 | } 145 | --------------------------------------------------------------------------------