├── .gitignore ├── README.md ├── docs ├── DOCS.md ├── documentation │ ├── .nojekyll │ ├── assets │ │ ├── highlight.css │ │ ├── main.js │ │ ├── search.js │ │ └── style.css │ ├── classes │ │ ├── domain_customer_account_customer_account.CustomerAccount.html │ │ ├── domain_customer_address_customer_address.CustomerAddress.html │ │ ├── domain_customer_address_customer_address.CustomerAddressInvalidError.html │ │ ├── domain_customer_address_customer_address.CustomerAddressNonUSError.html │ │ ├── domain_customer_playlist_customer_playlist.CustomerPlaylist.html │ │ ├── errors.MaxNumberOfPlaylistsError.html │ │ ├── errors.MaxPlaylistSizeError.html │ │ ├── errors.PaymentInvalidError.html │ │ ├── errors.SubscriptionAlreadyUpgradedError.html │ │ └── errors.ValidationError.html │ ├── enums │ │ ├── models_customer_account_types.PaymentStatus.html │ │ └── models_customer_account_types.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 │ ├── interfaces │ │ └── entity_domain_event.ICreateDomainEvent.html │ ├── media │ │ ├── diagram.png │ │ └── header.png │ ├── modules.html │ ├── modules │ │ ├── domain_customer_account_customer_account.html │ │ ├── domain_customer_address_customer_address.html │ │ ├── domain_customer_playlist_customer_playlist.html │ │ ├── dto_customer_account_customer_account.html │ │ ├── dto_customer_address_customer_address.html │ │ ├── dto_customer_playlist_customer_playlist.html │ │ ├── entity_domain_event.html │ │ ├── errors.html │ │ ├── events_customer_account_created.html │ │ ├── events_customer_account_upgraded.html │ │ ├── events_customer_playlist_created.html │ │ ├── models_customer_account_types.html │ │ ├── models_customer_address_types.html │ │ ├── models_customer_playlist_types.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 │ │ ├── entity_domain_event.DomainEvent.html │ │ ├── models_customer_account_types.CreateCustomerAccountProps.html │ │ ├── models_customer_account_types.CustomerAccountProps.html │ │ ├── models_customer_account_types.NewCustomerAccountProps.html │ │ ├── models_customer_address_types.CustomerAddressProps.html │ │ ├── models_customer_playlist_types.CreateCustomerPlaylistProps.html │ │ ├── models_customer_playlist_types.CustomerPlaylistProps.html │ │ └── models_customer_playlist_types.NewCustomerPlaylistProps.html │ └── variables │ │ ├── events_customer_account_created.eventName.html │ │ ├── events_customer_account_created.eventSource.html │ │ ├── events_customer_account_upgraded.eventName.html │ │ ├── events_customer_account_upgraded.eventSource.html │ │ ├── events_customer_playlist_created.eventName.html │ │ └── events_customer_playlist_created.eventSource.html └── images │ ├── diagram.png │ ├── header-part-one.png │ └── header.png ├── onion-sounds ├── .gitignore ├── .npmignore ├── __mocks__ │ ├── 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 │ │ ├── domain │ │ │ ├── customer-account │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── customer-account.test.ts.snap │ │ │ │ ├── customer-account.test.ts │ │ │ │ ├── customer-account.ts │ │ │ │ └── index.ts │ │ │ ├── customer-address │ │ │ │ ├── customer-address.test.ts │ │ │ │ └── customer-address.ts │ │ │ └── customer-playlist │ │ │ │ ├── __snapshots__ │ │ │ │ └── customer-playlist.test.ts.snap │ │ │ │ ├── customer-playlist.test.ts │ │ │ │ ├── customer-playlist.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 │ │ ├── entity │ │ │ ├── aggregate-root.ts │ │ │ ├── domain-event.ts │ │ │ ├── entity.ts │ │ │ ├── index.ts │ │ │ └── value-object.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ ├── max-number-of-playlists-error.ts │ │ │ ├── max-playlist-size-error.ts │ │ │ ├── payment-invalid-error.ts │ │ │ ├── playlist-not-found-error.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 │ │ ├── models │ │ │ ├── customer-account-types.ts │ │ │ ├── customer-address-types.ts │ │ │ ├── customer-playlist-types.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── create-customer-account-repository │ │ │ │ ├── create-customer-account-repository.test.ts │ │ │ │ ├── create-customer-account-repository.ts │ │ │ │ └── index.ts │ │ │ ├── publish-event-recipient │ │ │ │ ├── index.ts │ │ │ │ ├── publish-event-recipient.test.ts │ │ │ │ └── publish-event-recipient.ts │ │ │ ├── retrieve-customer-account-repository │ │ │ │ ├── index.ts │ │ │ │ ├── retrieve-customer-account-repository.test.ts │ │ │ │ └── retrieve-customer-account-repository.ts │ │ │ └── update-customer-account-repository │ │ │ │ ├── index.ts │ │ │ │ ├── update-customer-account-repository.test.ts │ │ │ │ └── update-customer-account-repository.ts │ │ ├── schemas │ │ │ ├── customer-account.schema.test.ts │ │ │ ├── customer-account.schema.ts │ │ │ ├── customer-address.test.ts │ │ │ ├── customer-address.ts │ │ │ ├── customer-playlist.schema.test.ts │ │ │ └── customer-playlist.schema.ts │ │ └── use-cases │ │ │ ├── add-song-to-playlist │ │ │ ├── add-song-to-playlist.test.ts │ │ │ └── add-song-to-playlist.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 │ │ │ ├── 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 | # clean serverless code part 2 2 | 3 | Part 2 of using hexagonal architectures in our Serverless solutions to ensure clean separation of code and infrastructure; with examples written in the AWS CDK and TypeScript. 4 | 5 | ![image](./docs/images/header.png) 6 | 7 | The article can be found here: https://leejamesgilmore.medium.com/serverless-clean-architecture-code-with-domain-driven-design-part-2-9ad0882ff85b 8 | 9 | ## Part 1 10 | 11 | ![image](./docs/images/header-part-one.png) 12 | 13 | Part 1 of the article can be found here: https://leejamesgilmore.medium.com/serverless-clean-architecture-code-with-domain-driven-design-852796846d28 14 | 15 | ## Getting started 16 | 17 | To deploy the solution please look at the steps in the article linked above. 18 | 19 | ** The information and code provided are my own and I accept no responsibility on the use of the information. ** 20 | -------------------------------------------------------------------------------- /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 |
67 |
-------------------------------------------------------------------------------- /docs/documentation/media/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/clean-serverless-code-part-2/8cbe6875bb0f11a564c049cb225585a3c19c294b/docs/documentation/media/diagram.png -------------------------------------------------------------------------------- /docs/documentation/media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/clean-serverless-code-part-2/8cbe6875bb0f11a564c049cb225585a3c19c294b/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 |
67 |
-------------------------------------------------------------------------------- /docs/documentation/modules/models_customer_address_types.html: -------------------------------------------------------------------------------- 1 | models/customer-address-types | Onion Sounds - Customer Account Domain Service - v0.1.0
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Module models/customer-address-types

18 |
19 |
20 |
21 |
22 |

Index

23 |
24 |

Type Aliases

25 |
27 |
67 |
-------------------------------------------------------------------------------- /docs/images/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/clean-serverless-code-part-2/8cbe6875bb0f11a564c049cb225585a3c19c294b/docs/images/diagram.png -------------------------------------------------------------------------------- /docs/images/header-part-one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/clean-serverless-code-part-2/8cbe6875bb0f11a564c049cb225585a3c19c294b/docs/images/header-part-one.png -------------------------------------------------------------------------------- /docs/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leegilmorecode/clean-serverless-code-part-2/8cbe6875bb0f11a564c049cb225585a3c19c294b/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/__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 | '^@repositories/(.*)': '/stateless/src/repositories/$1', 14 | '^@schemas/(.*)': '/stateless/src/schemas/$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 { SubscriptionAlreadyUpgradedError } from '@errors/subscription-already-upgraded-error'; 6 | import { ValidationError } from '@errors/validation-error'; 7 | import { errorHandler } from './error-handler'; 8 | 9 | describe('error-handler', () => { 10 | it('should default the error and status code on unknown instance type', () => { 11 | // arrange 12 | const error = null; 13 | 14 | // act / assert 15 | expect(errorHandler(error)).toMatchInlineSnapshot(` 16 | Object { 17 | "body": "\\"An error has occurred\\"", 18 | "statusCode": 500, 19 | } 20 | `); 21 | }); 22 | 23 | it('should default the error and status code on unknown error', () => { 24 | // arrange 25 | const error = new Error('unknown error'); 26 | 27 | // act / assert 28 | expect(errorHandler(error)).toMatchInlineSnapshot(` 29 | Object { 30 | "body": "\\"An error has occurred\\"", 31 | "statusCode": 500, 32 | } 33 | `); 34 | }); 35 | 36 | it('should return the correct response on ValidationError', () => { 37 | // arrange 38 | const error = new ValidationError('this is a validation error'); 39 | 40 | // act / assert 41 | expect(errorHandler(error)).toMatchInlineSnapshot(` 42 | Object { 43 | "body": "\\"this is a validation error\\"", 44 | "statusCode": 400, 45 | } 46 | `); 47 | }); 48 | 49 | it('should return the correct response on PaymentInvalidError', () => { 50 | // arrange 51 | const error = new PaymentInvalidError('this is a payment invalid error'); 52 | 53 | // act / assert 54 | expect(errorHandler(error)).toMatchInlineSnapshot(` 55 | Object { 56 | "body": "\\"this is a payment invalid error\\"", 57 | "statusCode": 400, 58 | } 59 | `); 60 | }); 61 | 62 | it('should return the correct response on PaymentInvalidError', () => { 63 | // arrange 64 | const error = new MaxNumberOfPlaylistsError('max number of playlists'); 65 | 66 | // act / assert 67 | expect(errorHandler(error)).toMatchInlineSnapshot(` 68 | Object { 69 | "body": "\\"max number of playlists\\"", 70 | "statusCode": 400, 71 | } 72 | `); 73 | }); 74 | 75 | it('should return the correct response on PaymentInvalidError', () => { 76 | // arrange 77 | const error = new MaxPlaylistSizeError('max playlist size'); 78 | 79 | // act / assert 80 | expect(errorHandler(error)).toMatchInlineSnapshot(` 81 | Object { 82 | "body": "\\"max playlist size\\"", 83 | "statusCode": 400, 84 | } 85 | `); 86 | }); 87 | 88 | it('should return the correct response on PlaylistNotFoundError', () => { 89 | // arrange 90 | const error = new PlaylistNotFoundError('playlist not found'); 91 | 92 | // act / assert 93 | expect(errorHandler(error)).toMatchInlineSnapshot(` 94 | Object { 95 | "body": "\\"playlist not found\\"", 96 | "statusCode": 400, 97 | } 98 | `); 99 | }); 100 | 101 | it('should return the correct response on SubscriptionAlreadyUpgradedError', () => { 102 | // arrange 103 | const error = new SubscriptionAlreadyUpgradedError( 104 | 'account already upgraded' 105 | ); 106 | 107 | // act / assert 108 | expect(errorHandler(error)).toMatchInlineSnapshot(` 109 | Object { 110 | "body": "\\"account already upgraded\\"", 111 | "statusCode": 400, 112 | } 113 | `); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /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 | default: 24 | errorMessage = 'An error has occurred'; 25 | statusCode = 500; 26 | break; 27 | } 28 | } else { 29 | errorMessage = 'An error has occurred'; 30 | statusCode = 500; 31 | } 32 | 33 | logger.error(errorMessage); 34 | 35 | return { 36 | statusCode: statusCode, 37 | body: JSON.stringify(errorMessage), 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /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('create-customer-account-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: ['sonegone'], 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\\":[\\"sonegone\\"]}", 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 | await expect( 92 | addSongToPlaylistAdapter((event as any))). 93 | resolves.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/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 | // adapts a proxy event (infra) into a dto for the use case 26 | // (adapter) --> use case --> domain 27 | export const addSongToPlaylistAdapter = async ({ 28 | body, 29 | pathParameters, 30 | }: APIGatewayProxyEvent): Promise => { 31 | try { 32 | if (!body) throw new ValidationError('no song body'); 33 | if (!pathParameters || !pathParameters?.id) 34 | throw new ValidationError( 35 | 'no customer account id in the path parameters of the event' 36 | ); 37 | if (!pathParameters || !pathParameters?.playlistId) 38 | throw new ValidationError( 39 | 'no playlist id in the path parameters of the event' 40 | ); 41 | 42 | const { id, playlistId } = pathParameters; 43 | 44 | logger.info(`customer account id: ${id}, playlist id: ${playlistId}`); 45 | 46 | const newCustomerPlaylistSong: NewCustomerPlaylistSongDto = 47 | JSON.parse(body); 48 | 49 | schemaValidator(schema, newCustomerPlaylistSong); 50 | 51 | const updatedPlaylist: CustomerPlaylistDto = await addSongToPlaylistUseCase( 52 | id, 53 | playlistId, 54 | newCustomerPlaylistSong 55 | ); 56 | 57 | logger.info( 58 | `song ${newCustomerPlaylistSong.songId} added to playlist ${playlistId} for account ${id}` 59 | ); 60 | 61 | metrics.addMetric('SuccessfulAddSongToPlaylist', MetricUnits.Count, 1); 62 | 63 | return { 64 | statusCode: 201, 65 | body: JSON.stringify(updatedPlaylist), 66 | }; 67 | } catch (error) { 68 | return errorHandler(error); 69 | } 70 | }; 71 | 72 | export const handler = middy(addSongToPlaylistAdapter) 73 | .use(injectLambdaContext(logger)) 74 | .use(captureLambdaHandler(tracer)) 75 | .use(logMetrics(metrics)); 76 | -------------------------------------------------------------------------------- /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-sont-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(`"[{\\"instancePath\\":\\"/songId\\",\\"schemaPath\\":\\"#/properties/songId/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"`); 23 | }); 24 | 25 | it('should not validate if the songId is null', () => { 26 | // arrange 27 | const payload = { 28 | songId: null, // invalid 29 | }; 30 | // act / assert 31 | expect(() => 32 | schemaValidator(schema, payload)). 33 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/songId\\",\\"schemaPath\\":\\"#/properties/songId/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"`); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /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 | } from '@dto/customer-account'; 7 | import { 8 | NewCustomerAccountProps, 9 | PaymentStatus, 10 | SubscriptionType, 11 | } from '@models/customer-account-types'; 12 | 13 | import { APIGatewayProxyEvent } from 'aws-lambda'; 14 | import { createCustomerAccountAdapter } from '@adapters/primary/create-customer-account/create-customer-account.adapter'; 15 | 16 | let event: Partial; 17 | let customerAccount: CustomerAccountDto; 18 | 19 | describe('create-customer-account-handler', () => { 20 | afterAll(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | beforeEach(() => { 25 | customerAccount = { 26 | id: '111', 27 | firstName: 'Gilmore', 28 | surname: 'Lee', 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(createCustomerAccountUseCase, 'createCustomerAccountUseCase') 46 | .mockResolvedValue(customerAccount); 47 | 48 | const payload: NewCustomerAccountDto = { 49 | firstName: 'Lee', 50 | surname: 'Gilmore', 51 | customerAddress: { 52 | addressLineOne: 'line one', 53 | addressLineTwo: 'line two', 54 | addressLineThree: 'line three', 55 | addressLineFour: 'line four', 56 | addressLineFive: 'line five', 57 | postCode: 'ne11bb', 58 | }, 59 | }; 60 | 61 | event = { 62 | body: JSON.stringify(payload), 63 | }; 64 | }); 65 | 66 | it('should return the correct response on success', async () => { 67 | // act & assert 68 | await expect(createCustomerAccountAdapter((event as any))).resolves. 69 | toMatchInlineSnapshot(` 70 | Object { 71 | "body": "{\\"id\\":\\"111\\",\\"firstName\\":\\"Gilmore\\",\\"surname\\":\\"Lee\\",\\"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\\"}}", 72 | "statusCode": 201, 73 | } 74 | `); 75 | }); 76 | 77 | it('should throw a validation error if the payload is invalid', async () => { 78 | // arrange 79 | const payload: NewCustomerAccountProps = { 80 | firstName: '', // invalid 81 | surname: 'Gilmore', 82 | customerAddress: { 83 | addressLineOne: 'line one', 84 | addressLineTwo: 'line two', 85 | addressLineThree: 'line three', 86 | addressLineFour: 'line four', 87 | addressLineFive: 'line five', 88 | postCode: 'ne11bb', 89 | }, 90 | }; 91 | 92 | event = { 93 | body: JSON.stringify(payload), 94 | }; 95 | 96 | // act & assert 97 | await expect(createCustomerAccountAdapter((event as any))).resolves. 98 | toMatchInlineSnapshot(` 99 | Object { 100 | "body": "\\"[{\\\\\\"instancePath\\\\\\":\\\\\\"/firstName\\\\\\",\\\\\\"schemaPath\\\\\\":\\\\\\"#/properties/firstName/pattern\\\\\\",\\\\\\"keyword\\\\\\":\\\\\\"pattern\\\\\\",\\\\\\"params\\\\\\":{\\\\\\"pattern\\\\\\":\\\\\\"^[a-zA-Z]+$\\\\\\"},\\\\\\"message\\\\\\":\\\\\\"must match pattern \\\\\\\\\\\\\\"^[a-zA-Z]+$\\\\\\\\\\\\\\"\\\\\\"}]\\"", 101 | "statusCode": 400, 102 | } 103 | `); 104 | }); 105 | 106 | it('should return the correct response on error', async () => { 107 | // arrange 108 | event = {} as any; 109 | 110 | // act & assert 111 | await expect(createCustomerAccountAdapter(event as any)).resolves 112 | .toMatchInlineSnapshot(` 113 | Object { 114 | "body": "\\"no order body\\"", 115 | "statusCode": 400, 116 | } 117 | `); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /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 | // adapts a proxy event (infra) into a dto for the use case 26 | // (adapter) --> use case --> domain 27 | export const createCustomerAccountAdapter = async ({ 28 | body, 29 | }: APIGatewayProxyEvent): Promise => { 30 | try { 31 | if (!body) throw new ValidationError('no order body'); 32 | 33 | const customerAccount: NewCustomerAccountDto = JSON.parse(body); 34 | 35 | schemaValidator(schema, customerAccount); 36 | 37 | logger.info(`customer account: ${JSON.stringify(customerAccount)}`); 38 | 39 | const createdAccount: CustomerAccountDto = 40 | await createCustomerAccountUseCase(customerAccount); 41 | 42 | logger.info(`customer account created: ${JSON.stringify(createdAccount)}`); 43 | 44 | metrics.addMetric('SuccessfulCustomerAccountCreated', MetricUnits.Count, 1); 45 | 46 | return { 47 | statusCode: 201, 48 | body: JSON.stringify(createdAccount), 49 | }; 50 | } catch (error) { 51 | return errorHandler(error); 52 | } 53 | }; 54 | 55 | export const handler = middy(createCustomerAccountAdapter) 56 | .use(injectLambdaContext(logger)) 57 | .use(captureLambdaHandler(tracer)) 58 | .use(logMetrics(metrics)); 59 | -------------------------------------------------------------------------------- /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-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(`"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"`); 41 | }); 42 | 43 | it('should not validate if the firstName is invalid', () => { 44 | const payload = { 45 | firstName: '±', // invalid 46 | surname: null, 47 | customerAddress: { 48 | addressLineOne: 'line one', 49 | addressLineTwo: 'line two', 50 | addressLineThree: 'line three', 51 | addressLineFour: 'line four', 52 | addressLineFive: 'line five', 53 | postCode: 'ne11bb', 54 | }, 55 | }; 56 | expect(() => 57 | schemaValidator(schema, payload)). 58 | toThrowErrorMatchingInlineSnapshot(`"[{\\"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\\"}]"`); 59 | }); 60 | 61 | it('should not validate if the surname is null', () => { 62 | // arrange 63 | const payload = { 64 | firstName: 'Lee', 65 | surname: null, // invalid 66 | customerAddress: { 67 | addressLineOne: 'line one', 68 | addressLineTwo: 'line two', 69 | addressLineThree: 'line three', 70 | addressLineFour: 'line four', 71 | addressLineFive: 'line five', 72 | postCode: 'ne11bb', 73 | }, 74 | }; 75 | // act / assert 76 | expect(() => 77 | schemaValidator(schema, payload)). 78 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"`); 79 | }); 80 | 81 | it('should not validate if the firstName is invalid', () => { 82 | // arrange 83 | const payload = { 84 | firstName: 'Lee', 85 | surname: '±', // invalid 86 | customerAddress: { 87 | addressLineOne: 'line one', 88 | addressLineTwo: 'line two', 89 | addressLineThree: 'line three', 90 | addressLineFour: 'line four', 91 | addressLineFive: 'line five', 92 | postCode: 'ne11bb', 93 | }, 94 | }; 95 | // act / assert 96 | expect(() => 97 | schemaValidator(schema, payload)). 98 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"`); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /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-account-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 | // adapts a proxy event (infra) into a dto for the use case 26 | // (adapter) --> use case --> domain 27 | export const createCustomerPlaylistAdapter = async ({ 28 | body, 29 | pathParameters, 30 | }: APIGatewayProxyEvent): Promise => { 31 | try { 32 | if (!body) throw new ValidationError('no playlist body'); 33 | if (!pathParameters || !pathParameters?.id) 34 | throw new ValidationError('no id in the path parameters of the event'); 35 | 36 | const { id } = pathParameters; 37 | 38 | logger.info(`customer account id: ${id}`); 39 | 40 | const customerPlaylist: NewCustomerPlaylistDto = JSON.parse(body); 41 | 42 | schemaValidator(schema, customerPlaylist); 43 | 44 | logger.info(`customer account: ${JSON.stringify(customerPlaylist)}`); 45 | 46 | const createdAccount: CustomerPlaylistDto = 47 | await createCustomerPlaylistUseCase(id, customerPlaylist); 48 | 49 | logger.info(`customer account created: ${JSON.stringify(createdAccount)}`); 50 | 51 | metrics.addMetric( 52 | 'SuccessfulCustomerPlaylistCreated', 53 | MetricUnits.Count, 54 | 1 55 | ); 56 | 57 | return { 58 | statusCode: 201, 59 | body: JSON.stringify(createdAccount), 60 | }; 61 | } catch (error) { 62 | return errorHandler(error); 63 | } 64 | }; 65 | 66 | export const handler = middy(createCustomerPlaylistAdapter) 67 | .use(injectLambdaContext(logger)) 68 | .use(captureLambdaHandler(tracer)) 69 | .use(logMetrics(metrics)); 70 | -------------------------------------------------------------------------------- /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 | PaymentStatus, 5 | SubscriptionType, 6 | } from '@models/customer-account-types'; 7 | 8 | import { APIGatewayProxyEvent } from 'aws-lambda'; 9 | import { CustomerAccountDto } from '@dto/customer-account'; 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: 'Gilmore', 24 | surname: 'Lee', 25 | subscriptionType: SubscriptionType.Basic, 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(retrieveCustomerAccountUseCase, 'retrieveCustomerAccountUseCase') 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(retrieveCustomerAccountAdapter((event as any))).resolves. 54 | toMatchInlineSnapshot(` 55 | Object { 56 | "body": "{\\"id\\":\\"111\\",\\"firstName\\":\\"Gilmore\\",\\"surname\\":\\"Lee\\",\\"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\\"}}", 57 | "statusCode": 200, 58 | } 59 | `); 60 | }); 61 | 62 | it('should return the correct response on error', async () => { 63 | // arrange 64 | event = {} as any; 65 | 66 | // act & assert 67 | await expect(retrieveCustomerAccountAdapter(event as any)).resolves 68 | .toMatchInlineSnapshot(` 69 | Object { 70 | "body": "\\"no id in the path parameters of the event\\"", 71 | "statusCode": 400, 72 | } 73 | `); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /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 | // adapts a proxy event (infra) into a dto for the use case 21 | // (adapter) --> use case --> domain 22 | export const retrieveCustomerAccountAdapter = async ({ 23 | pathParameters, 24 | }: APIGatewayProxyEvent): Promise => { 25 | try { 26 | if (!pathParameters || !pathParameters?.id) 27 | throw new ValidationError('no id in the path parameters of the event'); 28 | 29 | const { id } = pathParameters; 30 | 31 | logger.info(`customer account id: ${id}`); 32 | 33 | const customerAccount: CustomerAccountDto = 34 | await retrieveCustomerAccountUseCase(id); 35 | 36 | logger.info(`customer account: ${JSON.stringify(customerAccount)}`); 37 | 38 | metrics.addMetric( 39 | 'SuccessfulCustomerAccountRecieved', 40 | MetricUnits.Count, 41 | 1 42 | ); 43 | 44 | return { 45 | statusCode: 200, 46 | body: JSON.stringify(customerAccount), 47 | }; 48 | } catch (error) { 49 | return errorHandler(error); 50 | } 51 | }; 52 | 53 | export const handler = middy(retrieveCustomerAccountAdapter) 54 | .use(injectLambdaContext(logger)) 55 | .use(captureLambdaHandler(tracer)) 56 | .use(logMetrics(metrics)); 57 | -------------------------------------------------------------------------------- /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 | PaymentStatus, 5 | SubscriptionType, 6 | } from '@models/customer-account-types'; 7 | 8 | import { APIGatewayProxyEvent } from 'aws-lambda'; 9 | import { CustomerAccountDto } from '@dto/customer-account'; 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: 'Gilmore', 24 | surname: 'Lee', 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\\":\\"Gilmore\\",\\"surname\\":\\"Lee\\",\\"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( 70 | upgradeCustomerAccountAdapter((event as any))). 71 | resolves.toMatchInlineSnapshot(` 72 | Object { 73 | "body": "\\"no id in the path parameters of the event\\"", 74 | "statusCode": 400, 75 | } 76 | `); 77 | }); 78 | 79 | it('should return the correct response on error', async () => { 80 | // arrange 81 | event = {} as any; 82 | 83 | // act & assert 84 | await expect(upgradeCustomerAccountAdapter(event as any)).resolves 85 | .toMatchInlineSnapshot(` 86 | Object { 87 | "body": "\\"no id in the path parameters of the event\\"", 88 | "statusCode": 400, 89 | } 90 | `); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /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 | // adapts a proxy event (infra) into a dto for the use case 21 | // (adapter) --> use case --> domain 22 | export const upgradeCustomerAccountAdapter = async ({ 23 | pathParameters, 24 | }: APIGatewayProxyEvent): Promise => { 25 | try { 26 | if (!pathParameters || !pathParameters?.id) 27 | throw new ValidationError('no id in the path parameters of the event'); 28 | 29 | const { id } = pathParameters; 30 | 31 | logger.info(`customer account id: ${id}`); 32 | 33 | const customerAccount: CustomerAccountDto = 34 | await upgradeCustomerAccountUseCase(id); 35 | 36 | logger.info( 37 | `upgraded customer account: ${JSON.stringify(customerAccount)}` 38 | ); 39 | 40 | metrics.addMetric( 41 | 'SuccessfulCustomerAccountUpgraded', 42 | MetricUnits.Count, 43 | 1 44 | ); 45 | metrics.addMetadata('CustomerAccountId', id); 46 | 47 | return { 48 | statusCode: 200, 49 | body: JSON.stringify(customerAccount), 50 | }; 51 | } catch (error) { 52 | return errorHandler(error); 53 | } 54 | }; 55 | 56 | export const handler = middy(upgradeCustomerAccountAdapter) 57 | .use(injectLambdaContext(logger)) 58 | .use(captureLambdaHandler(tracer)) 59 | .use(logMetrics(metrics)); 60 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/adapters/secondary/database-adapter/database-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PaymentStatus, 3 | SubscriptionType, 4 | } from '@models/customer-account-types'; 5 | import { 6 | createAccount, 7 | retrieveAccount, 8 | updateAccount, 9 | } from '@adapters/secondary/database-adapter/database-adapter'; 10 | 11 | import { CustomerAccountDto } from '@dto/customer-account'; 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 | // domain --> use case --> (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 | // domain --> use case --> (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 | // domain --> use case via port --> (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 | // domain --> use case --> (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/domain/customer-account/customer-account.ts: -------------------------------------------------------------------------------- 1 | import * as customerAccountCreatedEvent from '@events/customer-account-created'; 2 | import * as customerAccountUpdatedEvent from '@events/customer-account-updated'; 3 | import * as customerAccountUpgradedEvent from '@events/customer-account-upgraded'; 4 | 5 | import { 6 | CreateCustomerAccountProps, 7 | NewCustomerAccountProps, 8 | PaymentStatus, 9 | SubscriptionType, 10 | } from '@models/customer-account-types'; 11 | import { 12 | CustomerPlaylistProps, 13 | NewCustomerPlaylistProps, 14 | } from '@models/customer-playlist-types'; 15 | 16 | import { AggregateRoot } from '@entity/aggregate-root'; 17 | import { CustomerAccountDto } from '@dto/customer-account'; 18 | import { CustomerAddress } from '@domain/customer-address/customer-address'; 19 | import { CustomerPlaylist } from '@domain/customer-playlist'; 20 | import { DomainEvent } from '@entity/domain-event'; 21 | import { MaxNumberOfPlaylistsError } from '@errors/max-number-of-playlists-error'; 22 | import { PaymentInvalidError } from '@errors/payment-invalid-error'; 23 | import { PlaylistNotFoundError } from '@errors/playlist-not-found-error'; 24 | import { SubscriptionAlreadyUpgradedError } from '@errors/subscription-already-upgraded-error'; 25 | import { schema } from '@schemas/customer-account.schema'; 26 | 27 | // only works with entities and value objects - calling the repositories 28 | // adapter --> use case --> (domain) <-- repository <-- adapter 29 | 30 | /** 31 | * Customer Account Domain Entity 32 | * 33 | * A customer account is an entity which allows customers to create a new account, and upgrade their 34 | * account subscription. 35 | */ 36 | export class CustomerAccount extends AggregateRoot { 37 | private constructor({ 38 | id, 39 | created, 40 | updated, 41 | ...props 42 | }: CreateCustomerAccountProps) { 43 | super(props, id, created, updated); 44 | } 45 | 46 | // retrieve all domain events in the aggregate inc children 47 | // adapter --> use case --> (domain) 48 | public retrieveDomainEvents(): DomainEvent[] { 49 | const childEvents: DomainEvent[] = this.props.playlists 50 | .map((playList: CustomerPlaylist) => playList.domainEvents) 51 | .reduce( 52 | (domainEvent: DomainEvent[], item: DomainEvent[]) => 53 | domainEvent.concat(item), 54 | [] 55 | ); 56 | 57 | const existingEvents = [...this.domainEvents.concat(childEvents, [])]; 58 | 59 | // clear down the existing events on the queue 60 | this.clearDomainEvents(); 61 | this.props.playlists.forEach((playlist) => playlist.clearDomainEvents()); 62 | 63 | return existingEvents; 64 | } 65 | 66 | // retrieve an instance of the playlist on a given account 67 | // adapter --> use case --> (domain) 68 | public retrievePlaylist(playlistId: string): CustomerPlaylist { 69 | const playlist: CustomerPlaylist | undefined = this.props.playlists.find( 70 | (playlist: CustomerPlaylist) => playlist.id === playlistId 71 | ); 72 | 73 | if (playlist === undefined) { 74 | throw new PlaylistNotFoundError(`Playlist ${playlistId} not found`); 75 | } 76 | return playlist; 77 | } 78 | 79 | // create an instance of a new customer account through a factory method 80 | // adapter --> use case --> (domain) 81 | public static createAccount(props: NewCustomerAccountProps): CustomerAccount { 82 | const customerAccountProps: CreateCustomerAccountProps = { 83 | firstName: props.firstName, 84 | surname: props.surname, 85 | subscriptionType: SubscriptionType.Basic, 86 | paymentStatus: PaymentStatus.Valid, 87 | playlists: [], 88 | customerAddress: CustomerAddress.create(props.customerAddress), 89 | }; 90 | 91 | const instance: CustomerAccount = new CustomerAccount(customerAccountProps); 92 | instance.validate(schema); 93 | 94 | instance.addDomainEvent({ 95 | event: instance.toDto(), 96 | eventName: customerAccountCreatedEvent.eventName, 97 | source: customerAccountCreatedEvent.eventSource, 98 | eventSchema: customerAccountCreatedEvent.eventSchema, 99 | eventVersion: customerAccountCreatedEvent.eventVersion, 100 | }); 101 | 102 | return instance; 103 | } 104 | 105 | // upgrade a customer account from basic to advanced through a service (use case) 106 | // adapter --> use case --> (domain) 107 | public upgradeAccount(): void { 108 | // only allow an upgrade if the payment status is valid 109 | if (this.props.paymentStatus === PaymentStatus.Invalid) { 110 | throw new PaymentInvalidError('Payment is invalid - unable to upgrade'); 111 | } 112 | 113 | // we can not upgrade an account which is already upgraded 114 | if (this.props.subscriptionType === SubscriptionType.Upgraded) { 115 | throw new SubscriptionAlreadyUpgradedError( 116 | 'Subscription is already upgraded - unable to upgrade' 117 | ); 118 | } 119 | 120 | // update the account to upgraded 121 | this.props.subscriptionType = SubscriptionType.Upgraded; 122 | 123 | this.setUpdatedDate(); 124 | this.validate(schema); 125 | 126 | this.addDomainEvent({ 127 | event: this.toDto(), 128 | eventName: customerAccountUpgradedEvent.eventName, 129 | source: customerAccountUpgradedEvent.eventSource, 130 | eventSchema: customerAccountUpgradedEvent.eventSchema, 131 | eventVersion: customerAccountUpgradedEvent.eventVersion, 132 | }); 133 | } 134 | 135 | // create a customer playlist on the customer account through a service (use case) 136 | // adapter --> use case --> (domain) 137 | public createPlaylist(playlistName: string): CustomerPlaylist { 138 | const playlistProps: NewCustomerPlaylistProps = { playlistName }; 139 | const newPlaylist: CustomerPlaylist = 140 | CustomerPlaylist.createPlaylist(playlistProps); 141 | 142 | if (this.props.playlists.length === 2) { 143 | throw new MaxNumberOfPlaylistsError( 144 | 'maximum number of playlists reached' 145 | ); 146 | } 147 | 148 | // create the new playlist 149 | this.props.playlists.push(newPlaylist); 150 | 151 | this.setUpdatedDate(); 152 | this.validate(schema); 153 | 154 | this.addDomainEvent({ 155 | event: this.toDto(), 156 | eventName: customerAccountUpdatedEvent.eventName, 157 | source: customerAccountUpdatedEvent.eventSource, 158 | eventSchema: customerAccountUpdatedEvent.eventSchema, 159 | eventVersion: customerAccountUpdatedEvent.eventVersion, 160 | }); 161 | 162 | return newPlaylist; 163 | } 164 | 165 | // create a dto based on the domain instance 166 | public toDto(): CustomerAccountDto { 167 | return { 168 | id: this.id, 169 | created: this.created, 170 | updated: this.updated, 171 | firstName: this.props.firstName, 172 | surname: this.props.surname, 173 | subscriptionType: this.props.subscriptionType, 174 | paymentStatus: this.props.paymentStatus, 175 | playlists: this.props.playlists.map((item: CustomerPlaylist) => 176 | item.toDto() 177 | ), 178 | customerAddress: this.props.customerAddress.toDto(), 179 | }; 180 | } 181 | 182 | // create a domain object based on the dto 183 | public static toDomain(raw: CustomerAccountDto): CustomerAccount { 184 | const playlists: CustomerPlaylist[] = raw.playlists.map( 185 | (item: CustomerPlaylistProps) => CustomerPlaylist.toDomain(item) 186 | ); 187 | 188 | const customerAddress = CustomerAddress.create(raw.customerAddress); 189 | 190 | const instance = new CustomerAccount({ 191 | ...raw, 192 | playlists, 193 | customerAddress, 194 | }); 195 | return instance; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-account/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomerAccount } from './customer-account'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-address/customer-address.test.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAddress } from './customer-address'; 2 | import { CustomerAddressProps } from '@models/customer-address-types'; 3 | 4 | describe('customer-address', () => { 5 | it('should create a new customer address', () => { 6 | // arrange 7 | const props: CustomerAddressProps = { 8 | addressLineOne: 'line one', 9 | addressLineTwo: 'line two', 10 | addressLineThree: 'line three', 11 | addressLineFour: 'line four', 12 | addressLineFive: 'line five', 13 | postCode: 'ne11bb', 14 | }; 15 | 16 | // act 17 | const customerAddress: CustomerAddress = CustomerAddress.create(props); 18 | 19 | // assert 20 | expect(customerAddress).toMatchInlineSnapshot(` 21 | CustomerAddress { 22 | "props": Object { 23 | "addressLineFive": "line five", 24 | "addressLineFour": "line four", 25 | "addressLineOne": "line one", 26 | "addressLineThree": "line three", 27 | "addressLineTwo": "line two", 28 | "postCode": "ne11bb", 29 | }, 30 | } 31 | `); 32 | }); 33 | 34 | it('should throw an error if address line one is not supplied', () => { 35 | // arrange 36 | const props: CustomerAddressProps = { 37 | addressLineOne: null, // invalid 38 | addressLineTwo: 'line two', 39 | addressLineThree: 'line three', 40 | addressLineFour: 'line four', 41 | addressLineFive: 'line five', 42 | postCode: 'ne11bb', 43 | } as any; 44 | 45 | // act & assert 46 | expect(() => 47 | CustomerAddress.create(props) 48 | ).toThrowErrorMatchingInlineSnapshot( 49 | `"address line one and post code are required at a minimum"` 50 | ); 51 | }); 52 | 53 | it('should throw an error if postcode is not supplied', () => { 54 | // arrange 55 | const props: CustomerAddressProps = { 56 | addressLineOne: 'line one', 57 | addressLineTwo: 'line two', 58 | addressLineThree: 'line three', 59 | addressLineFour: 'line four', 60 | addressLineFive: 'line five', 61 | postCode: null, // invalid 62 | } as any; 63 | 64 | // act & assert 65 | expect(() => 66 | CustomerAddress.create(props) 67 | ).toThrowErrorMatchingInlineSnapshot( 68 | `"address line one and post code are required at a minimum"` 69 | ); 70 | }); 71 | 72 | it('should throw an error if customer in the US', () => { 73 | // arrange 74 | const props: CustomerAddressProps = { 75 | addressLineOne: 'line one', 76 | addressLineTwo: 'line two', 77 | addressLineThree: 'line three', 78 | addressLineFour: 'line four', 79 | addressLineFive: 'US', // invalid 80 | postCode: 'TX1155', 81 | } as any; 82 | 83 | // act & assert 84 | expect(() => 85 | CustomerAddress.create(props)). 86 | toThrowErrorMatchingInlineSnapshot(`"Unable to create accounts in the US"`); 87 | }); 88 | 89 | it('should return the correct dto', () => { 90 | // arrange 91 | const props: CustomerAddressProps = { 92 | addressLineOne: 'line one', 93 | addressLineTwo: 'line two', 94 | addressLineThree: 'line three', 95 | addressLineFour: 'line four', 96 | addressLineFive: 'line five', 97 | postCode: 'ne11bb', 98 | }; 99 | 100 | const customerAddress: CustomerAddress = CustomerAddress.create(props); 101 | 102 | // act & assert 103 | expect(customerAddress.toDto()).toMatchInlineSnapshot(` 104 | Object { 105 | "addressLineFive": "line five", 106 | "addressLineFour": "line four", 107 | "addressLineOne": "line one", 108 | "addressLineThree": "line three", 109 | "addressLineTwo": "line two", 110 | "postCode": "ne11bb", 111 | } 112 | `); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-address/customer-address.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAddressDto } from '@dto/customer-address'; 2 | import { CustomerAddressProps } from '@models/customer-address-types'; 3 | import { ValueObject } from '@entity/value-object'; 4 | import { schema } from '@schemas/customer-address'; 5 | 6 | export class CustomerAddressInvalidError extends Error { 7 | constructor(message: string) { 8 | super(message); 9 | this.name = 'CustomerAddressInvalidError'; 10 | } 11 | } 12 | 13 | export class CustomerAddressNonUSError extends Error { 14 | constructor(message: string) { 15 | super(message); 16 | this.name = 'CustomerAddressNonUSError'; 17 | } 18 | } 19 | 20 | export class CustomerAddress extends ValueObject { 21 | private constructor({ ...props }: CustomerAddressProps) { 22 | super(props); 23 | } 24 | 25 | // create a new version of the customer address through a factory method 26 | // after we have validated the value object logic (immutable version) 27 | public static create(customerAddress: CustomerAddressProps): CustomerAddress { 28 | if (!customerAddress.addressLineOne || !customerAddress.postCode) { 29 | throw new CustomerAddressInvalidError( 30 | 'address line one and post code are required at a minimum' 31 | ); 32 | } 33 | // this is made up business logic for the demo 34 | if (customerAddress.addressLineFive === 'US') { 35 | throw new CustomerAddressNonUSError( 36 | 'Unable to create accounts in the US' 37 | ); 38 | } 39 | 40 | const newCustomerAddress = new CustomerAddress(customerAddress); 41 | 42 | // validate the value object using the schema 43 | newCustomerAddress.validate(schema); 44 | 45 | return newCustomerAddress; 46 | } 47 | 48 | public toDto(): CustomerAddressDto { 49 | return { 50 | addressLineOne: this.props.addressLineOne, 51 | addressLineTwo: this.props.addressLineTwo, 52 | addressLineThree: this.props.addressLineThree, 53 | addressLineFour: this.props.addressLineFour, 54 | addressLineFive: this.props.addressLineFive, 55 | postCode: this.props.postCode, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-playlist/__snapshots__/customer-playlist.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`customer-playlist create playlist should create the new account successfully with a valid customerPlaylist 1`] = ` 4 | CustomerPlaylist { 5 | "_created": "2022-01-01T00:00:00.000Z", 6 | "_domainEvents": Array [ 7 | Object { 8 | "event": Object { 9 | "created": "2022-01-01T00:00:00.000Z", 10 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 11 | "playlistName": "testplaylist", 12 | "songIds": Array [], 13 | "updated": "2022-01-01T00:00:00.000Z", 14 | }, 15 | "eventDateTime": "2022-01-01T00:00:00.000Z", 16 | "eventName": "CustomerPlaylistCreated", 17 | "eventVersion": "1", 18 | "source": "com.customer-account-onion", 19 | }, 20 | ], 21 | "_id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 22 | "_updated": "2022-01-01T00:00:00.000Z", 23 | "props": Object { 24 | "created": "2022-01-01T00:00:00.000Z", 25 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 26 | "playlistName": "testplaylist", 27 | "songIds": Array [], 28 | "updated": "2022-01-01T00:00:00.000Z", 29 | }, 30 | } 31 | `; 32 | 33 | exports[`customer-playlist toDomain should create a domain object based on a dto 1`] = ` 34 | CustomerPlaylist { 35 | "_created": "2022-01-01T00:00:00.000Z", 36 | "_domainEvents": Array [], 37 | "_id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 38 | "_updated": "2022-01-01T00:00:00.000Z", 39 | "props": Object { 40 | "created": "2022-01-01T00:00:00.000Z", 41 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 42 | "playlistName": "testplaylist", 43 | "songIds": Array [ 44 | "123", 45 | "345", 46 | ], 47 | "updated": "2022-01-01T00:00:00.000Z", 48 | }, 49 | } 50 | `; 51 | 52 | exports[`customer-playlist toDto should create the correct dto 1`] = ` 53 | Object { 54 | "created": "2022-01-01T00:00:00.000Z", 55 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 56 | "playlistName": "testplaylist", 57 | "songIds": Array [], 58 | "updated": "2022-01-01T00:00:00.000Z", 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-playlist/customer-playlist.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomerPlaylistProps, 3 | NewCustomerPlaylistProps, 4 | } from '@models/customer-playlist-types'; 5 | 6 | import { CustomerPlaylist } from '@domain/customer-playlist'; 7 | import { CustomerPlaylistDto } from '@dto/customer-playlist'; 8 | 9 | let customerPlaylist: CustomerPlaylistDto = { 10 | created: '2022-01-01T00:00:00.000Z', 11 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 12 | updated: '2022-01-01T00:00:00.000Z', 13 | songIds: ['123', '345'], 14 | playlistName: 'testplaylist', 15 | }; 16 | 17 | describe('customer-playlist', () => { 18 | beforeAll(() => { 19 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 20 | }); 21 | 22 | afterAll(() => { 23 | jest.clearAllTimers(); 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | describe('create playlist', () => { 28 | it('should fail creating a new account with an invalid customerPlaylist', () => { 29 | // arrange 30 | const newCustomerPlaylist: NewCustomerPlaylistProps = { 31 | ...customerPlaylist, 32 | playlistName: '±', 33 | }; 34 | 35 | // act / assert 36 | expect(() => 37 | CustomerPlaylist.createPlaylist(newCustomerPlaylist) 38 | ).toThrowErrorMatchingInlineSnapshot( 39 | `"[{\\"instancePath\\":\\"/playlistName\\",\\"schemaPath\\":\\"#/properties/playlistName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 40 | ); 41 | }); 42 | 43 | it('should create the new account successfully with a valid customerPlaylist', () => { 44 | // arrange 45 | const newCustomerPaylist: NewCustomerPlaylistProps = { 46 | ...customerPlaylist, 47 | playlistName: 'testplaylist', 48 | }; 49 | 50 | // act 51 | const newPlaylist = CustomerPlaylist.createPlaylist(newCustomerPaylist); 52 | 53 | // assert 54 | expect(newPlaylist).toMatchSnapshot(); 55 | }); 56 | }); 57 | 58 | describe('toDto', () => { 59 | it('should create the correct dto', () => { 60 | // arrange 61 | const newCustomerPlaylist: NewCustomerPlaylistProps = { 62 | playlistName: 'testplaylist', 63 | }; 64 | 65 | // act 66 | const newPlaylist = CustomerPlaylist.createPlaylist(newCustomerPlaylist); 67 | 68 | // assert 69 | expect(newPlaylist.toDto()).toMatchSnapshot(); 70 | }); 71 | }); 72 | 73 | describe('toDomain', () => { 74 | it('should create a domain object based on a dto', () => { 75 | // arrange 76 | const customerPlaylistProps: CustomerPlaylistProps = { 77 | ...customerPlaylist, 78 | }; 79 | 80 | // act 81 | const playlist: CustomerPlaylist = CustomerPlaylist.toDomain( 82 | customerPlaylistProps 83 | ); 84 | 85 | // assert 86 | expect(playlist).toMatchSnapshot(); 87 | }); 88 | }); 89 | 90 | describe('addSongToPlaylist', () => { 91 | it('should successfully add the song to the playlist', () => { 92 | // arrange 93 | const newCustomerPaylist: NewCustomerPlaylistProps = { 94 | ...customerPlaylist, 95 | playlistName: 'testplaylist', 96 | }; 97 | const newPlaylist = CustomerPlaylist.createPlaylist(newCustomerPaylist); 98 | 99 | // act 100 | newPlaylist.addSongToPlaylist('songone'); 101 | 102 | // assert 103 | expect(newPlaylist).toMatchInlineSnapshot(` 104 | CustomerPlaylist { 105 | "_created": "2022-01-01T00:00:00.000Z", 106 | "_domainEvents": Array [ 107 | Object { 108 | "event": Object { 109 | "created": "2022-01-01T00:00:00.000Z", 110 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 111 | "playlistName": "testplaylist", 112 | "songIds": Array [ 113 | "songone", 114 | ], 115 | "updated": "2022-01-01T00:00:00.000Z", 116 | }, 117 | "eventDateTime": "2022-01-01T00:00:00.000Z", 118 | "eventName": "CustomerPlaylistCreated", 119 | "eventVersion": "1", 120 | "source": "com.customer-account-onion", 121 | }, 122 | Object { 123 | "event": Object { 124 | "created": "2022-01-01T00:00:00.000Z", 125 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 126 | "playlistName": "testplaylist", 127 | "songIds": Array [ 128 | "songone", 129 | ], 130 | "updated": "2022-01-01T00:00:00.000Z", 131 | }, 132 | "eventDateTime": "2022-01-01T00:00:00.000Z", 133 | "eventName": "SongAddedToPlaylist", 134 | "eventVersion": "1", 135 | "source": "com.customer-account-onion", 136 | }, 137 | ], 138 | "_id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 139 | "_updated": "2022-01-01T00:00:00.000Z", 140 | "props": Object { 141 | "created": "2022-01-01T00:00:00.000Z", 142 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 143 | "playlistName": "testplaylist", 144 | "songIds": Array [ 145 | "songone", 146 | ], 147 | "updated": "2022-01-01T00:00:00.000Z", 148 | }, 149 | } 150 | `); 151 | }); 152 | 153 | it('should throw an error if there are more than 4 songs on the playlist', () => { 154 | // arrange 155 | const newCustomerPaylist: NewCustomerPlaylistProps = { 156 | ...customerPlaylist, 157 | playlistName: 'testplaylist', 158 | }; 159 | const newPlaylist = CustomerPlaylist.createPlaylist(newCustomerPaylist); 160 | 161 | // act 162 | newPlaylist.addSongToPlaylist('songone'); 163 | newPlaylist.addSongToPlaylist('songtwo'); 164 | newPlaylist.addSongToPlaylist('songthree'); 165 | newPlaylist.addSongToPlaylist('songfour'); 166 | 167 | // assert 168 | expect(() => 169 | newPlaylist.addSongToPlaylist('songfive') 170 | ).toThrowErrorMatchingInlineSnapshot( 171 | `"the maximum playlist length is 4"` 172 | ); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-playlist/customer-playlist.ts: -------------------------------------------------------------------------------- 1 | import * as customerPlaylistCreatedEvent from '@events/customer-playlist-created'; 2 | import * as songAddedToPlaylistEvent from '@events/song-added-to-playlist'; 3 | 4 | import { 5 | CreateCustomerPlaylistProps, 6 | NewCustomerPlaylistProps, 7 | } from '@models/customer-playlist-types'; 8 | 9 | import { CustomerPlaylistDto } from '@dto/customer-playlist'; 10 | import { Entity } from '@entity/entity'; 11 | import { MaxPlaylistSizeError } from '@errors/max-playlist-size-error'; 12 | import { schema } from '@schemas/customer-playlist.schema'; 13 | 14 | /** 15 | * Customer Playlist Domain Entity 16 | * 17 | * A customer playlist is an entity which allows customers to manage one or more playlists. 18 | */ 19 | export class CustomerPlaylist extends Entity { 20 | private constructor({ 21 | id, 22 | created, 23 | updated, 24 | ...props 25 | }: CreateCustomerPlaylistProps) { 26 | super(props, id, created, updated); 27 | } 28 | 29 | // create an instance of a new customer playlist through a factory method 30 | public static createPlaylist( 31 | props: NewCustomerPlaylistProps 32 | ): CustomerPlaylist { 33 | const customerAccountProps: CreateCustomerPlaylistProps = { 34 | playlistName: props.playlistName, 35 | songIds: [], 36 | }; 37 | 38 | const instance: CustomerPlaylist = new CustomerPlaylist( 39 | customerAccountProps 40 | ); 41 | 42 | instance.validate(schema); 43 | 44 | instance.addDomainEvent({ 45 | event: instance.toDto(), 46 | eventName: customerPlaylistCreatedEvent.eventName, 47 | source: customerPlaylistCreatedEvent.eventSource, 48 | eventSchema: customerPlaylistCreatedEvent.eventSchema, 49 | eventVersion: customerPlaylistCreatedEvent.eventVersion, 50 | }); 51 | 52 | return instance; 53 | } 54 | 55 | // add a song to the playlist 56 | public addSongToPlaylist(songId: string): void { 57 | if (this.props.songIds.length >= 4) { 58 | throw new MaxPlaylistSizeError('the maximum playlist length is 4'); 59 | } 60 | 61 | this.props.songIds.push(songId); 62 | 63 | this.setUpdatedDate(); 64 | this.validate(schema); 65 | 66 | this.addDomainEvent({ 67 | event: this.toDto(), 68 | eventName: songAddedToPlaylistEvent.eventName, 69 | source: songAddedToPlaylistEvent.eventSource, 70 | eventSchema: songAddedToPlaylistEvent.eventSchema, 71 | eventVersion: songAddedToPlaylistEvent.eventVersion, 72 | }); 73 | } 74 | 75 | // create a dto based on the domain instance 76 | public toDto(): CustomerPlaylistDto { 77 | return { 78 | id: this.id, 79 | created: this.created, 80 | updated: this.updated, 81 | playlistName: this.props.playlistName, 82 | songIds: this.props.songIds, 83 | }; 84 | } 85 | 86 | // create a domain object based on the dto 87 | public static toDomain(raw: CustomerPlaylistDto): CustomerPlaylist { 88 | const instance = new CustomerPlaylist(raw); 89 | return instance; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/domain/customer-playlist/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomerPlaylist } from './customer-playlist'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/dto/customer-account/customer-account.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PaymentStatus, 3 | SubscriptionType, 4 | } from '@models/customer-account-types'; 5 | 6 | import { CustomerAddress } from '@domain/customer-address/customer-address'; 7 | import { CustomerAddressProps } from '@models/customer-address-types'; 8 | import { CustomerPlaylistProps } from '@models/customer-playlist-types'; 9 | 10 | export type CreateCustomerAccountDto = { 11 | id?: string; 12 | created?: string; 13 | updated?: string; 14 | firstName: string; 15 | surname: string; 16 | subscriptionType: SubscriptionType; 17 | paymentStatus: PaymentStatus; 18 | customerAddress: CustomerAddress; 19 | }; 20 | 21 | export type CustomerAccountDto = { 22 | id: string; 23 | created: string; 24 | updated: string; 25 | firstName: string; 26 | surname: string; 27 | subscriptionType: SubscriptionType; 28 | paymentStatus: PaymentStatus; 29 | playlists: CustomerPlaylistProps[]; 30 | customerAddress: CustomerAddressProps; 31 | }; 32 | 33 | export type NewCustomerAccountDto = { 34 | firstName: string; 35 | surname: string; 36 | customerAddress: CustomerAddressProps; 37 | }; 38 | -------------------------------------------------------------------------------- /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/entity/aggregate-root.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from './domain-event'; 2 | import { Entity } from './entity'; 3 | 4 | // denotes visually that this entity is the aggregate root 5 | // and stores the overall domain events for publishing 6 | export abstract class AggregateRoot extends Entity { 7 | // aggregates which implement this must create this method 8 | // to consolidate events from the full aggrgate root and children 9 | abstract retrieveDomainEvents(): DomainEvent[]; 10 | } 11 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/entity/domain-event.ts: -------------------------------------------------------------------------------- 1 | export type DomainEvent = { 2 | source: string; 3 | eventName: string; 4 | event: Record; 5 | eventDateTime: string; 6 | eventVersion: string; 7 | }; 8 | 9 | export interface ICreateDomainEvent { 10 | source: string; 11 | eventName: string; 12 | event: Record; 13 | eventSchema?: Record; 14 | eventVersion: string; 15 | } 16 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/entity/entity.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent, ICreateDomainEvent } from './domain-event'; 2 | 3 | import { schemaValidator } from '@packages/schema-validator'; 4 | import { v4 as uuid } from 'uuid'; 5 | 6 | export abstract class Entity { 7 | private readonly _id: string; 8 | private readonly _created: string; 9 | private _updated: string; 10 | protected props: T; 11 | private _domainEvents: DomainEvent[] = []; 12 | 13 | constructor(props: T, id?: string, created?: string, updated?: string) { 14 | // set default values on creation 15 | this._id = id ? id : uuid(); 16 | this._created = created ? created : this.getISOString(); 17 | this._updated = updated ? updated : this.getISOString(); 18 | this.props = { 19 | ...props, 20 | id: this.id, 21 | created: this.created, 22 | updated: this.updated, 23 | }; 24 | } 25 | 26 | public addDomainEvent(eventDetails: ICreateDomainEvent): void { 27 | // if we supply an event schema then validate before pushing the domain event 28 | // https://leejamesgilmore.medium.com/amazon-eventbridge-schema-validation-5b6c2c5ce3b3 29 | if (eventDetails.eventSchema) { 30 | schemaValidator(eventDetails.eventSchema, eventDetails.event); 31 | } 32 | 33 | const event: DomainEvent = { 34 | source: eventDetails.source, 35 | eventName: eventDetails.eventName, 36 | event: eventDetails.event, 37 | eventVersion: eventDetails.eventVersion, 38 | eventDateTime: this.getISOString(), 39 | }; 40 | 41 | this._domainEvents.push(event); 42 | } 43 | 44 | public clearDomainEvents(): void { 45 | this._domainEvents = []; 46 | } 47 | 48 | public get domainEvents(): DomainEvent[] { 49 | return this._domainEvents; 50 | } 51 | 52 | public get id(): string { 53 | return this._id; 54 | } 55 | 56 | public get created(): string { 57 | return this._created; 58 | } 59 | 60 | public get updated(): string { 61 | return this._updated; 62 | } 63 | 64 | public setUpdatedDate() { 65 | this._updated = this.getISOString(); 66 | } 67 | 68 | protected validate(schema: Record): void { 69 | schemaValidator(schema, this.props); 70 | } 71 | 72 | protected getISOString(): string { 73 | return new Date().toISOString(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/entity/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aggregate-root'; 2 | export * from './entity'; 3 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/entity/value-object.ts: -------------------------------------------------------------------------------- 1 | import { schemaValidator } from '@packages/schema-validator'; 2 | interface ValueObjectProps { 3 | [index: string]: any; 4 | } 5 | 6 | export abstract class ValueObject { 7 | protected props: T; 8 | 9 | constructor(props: T) { 10 | this.props = Object.freeze(props); 11 | } 12 | 13 | protected validate(schema: Record): void { 14 | schemaValidator(schema, this.props); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/models/customer-account-types.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAddress } from '@domain/customer-address/customer-address'; 2 | import { CustomerAddressProps } from '@models/customer-address-types'; 3 | import { CustomerPlaylist } from '@domain/customer-playlist'; 4 | 5 | export enum SubscriptionType { 6 | Basic = 'Basic', 7 | Upgraded = 'Upgraded', 8 | } 9 | 10 | export enum PaymentStatus { 11 | Valid = 'Valid', 12 | Invalid = 'Invalid', 13 | } 14 | 15 | export type CreateCustomerAccountProps = { 16 | id?: string; 17 | created?: string; 18 | updated?: string; 19 | firstName: string; 20 | surname: string; 21 | subscriptionType: SubscriptionType; 22 | paymentStatus: PaymentStatus; 23 | playlists: CustomerPlaylist[]; 24 | customerAddress: CustomerAddress; 25 | }; 26 | 27 | export type CustomerAccountProps = { 28 | id: string; 29 | created: string; 30 | updated: string; 31 | firstName: string; 32 | surname: string; 33 | subscriptionType: SubscriptionType; 34 | paymentStatus: PaymentStatus; 35 | playlists: CustomerPlaylist[]; 36 | customerAddress: CustomerAddress; 37 | }; 38 | 39 | export type NewCustomerAccountProps = { 40 | firstName: string; 41 | surname: string; 42 | customerAddress: CustomerAddressProps; 43 | }; 44 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/models/customer-address-types.ts: -------------------------------------------------------------------------------- 1 | export type CustomerAddressProps = { 2 | addressLineOne: string; 3 | addressLineTwo?: string; 4 | addressLineThree?: string; 5 | addressLineFour?: string; 6 | addressLineFive?: string; 7 | postCode: string; 8 | }; 9 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/models/customer-playlist-types.ts: -------------------------------------------------------------------------------- 1 | export type CustomerPlaylistProps = { 2 | id: string; 3 | created: string; 4 | updated: string; 5 | playlistName: string; 6 | songIds: string[]; 7 | }; 8 | 9 | export type CreateCustomerPlaylistProps = { 10 | id?: string; 11 | created?: string; 12 | updated?: string; 13 | playlistName: string; 14 | songIds: string[]; 15 | }; 16 | 17 | export type NewCustomerPlaylistProps = { 18 | playlistName: string; 19 | }; 20 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customer-playlist-types'; 2 | export * from './customer-account-types'; 3 | export * from './customer-address-types'; 4 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/create-customer-account-repository/create-customer-account-repository.test.ts: -------------------------------------------------------------------------------- 1 | import * as createAccount from '@adapters/secondary/database-adapter/database-adapter'; 2 | 3 | import { CustomerAccount } from '@domain/customer-account'; 4 | import { NewCustomerAccountProps } from '@models/customer-account-types'; 5 | import { createCustomerAccount } from '@repositories/create-customer-account-repository'; 6 | 7 | describe('create-customer-account-repository', () => { 8 | beforeAll(() => { 9 | jest.useFakeTimers().setSystemTime(new Date('2023-01-01')); 10 | }); 11 | afterAll(() => { 12 | jest.clearAllMocks(); 13 | jest.clearAllTimers(); 14 | }); 15 | 16 | it('should return the correct customer domain object', async () => { 17 | // arrange 18 | const customerAccountProps: NewCustomerAccountProps = { 19 | firstName: 'Gilmore', 20 | surname: 'Lee', 21 | customerAddress: { 22 | addressLineOne: 'line one', 23 | addressLineTwo: 'line two', 24 | addressLineThree: 'line three', 25 | addressLineFour: 'line four', 26 | addressLineFive: 'line five', 27 | postCode: 'ne11bb', 28 | }, 29 | }; 30 | 31 | const customer: CustomerAccount = 32 | CustomerAccount.createAccount(customerAccountProps); 33 | 34 | jest.spyOn(createAccount, 'createAccount').mockResolvedValue({ 35 | ...customer.toDto(), 36 | }); 37 | 38 | await expect(createCustomerAccount(customer)).resolves. 39 | toMatchInlineSnapshot(` 40 | CustomerAccount { 41 | "_created": "2023-01-01T00:00:00.000Z", 42 | "_domainEvents": Array [], 43 | "_id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 44 | "_updated": "2023-01-01T00:00:00.000Z", 45 | "props": Object { 46 | "created": "2023-01-01T00:00:00.000Z", 47 | "customerAddress": CustomerAddress { 48 | "props": Object { 49 | "addressLineFive": "line five", 50 | "addressLineFour": "line four", 51 | "addressLineOne": "line one", 52 | "addressLineThree": "line three", 53 | "addressLineTwo": "line two", 54 | "postCode": "ne11bb", 55 | }, 56 | }, 57 | "firstName": "Gilmore", 58 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 59 | "paymentStatus": "Valid", 60 | "playlists": Array [], 61 | "subscriptionType": "Basic", 62 | "surname": "Lee", 63 | "updated": "2023-01-01T00:00:00.000Z", 64 | }, 65 | } 66 | `); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/create-customer-account-repository/create-customer-account-repository.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAccount } from '@domain/customer-account'; 2 | import { CustomerAccountDto } from '@dto/customer-account'; 3 | import { createAccount } from '@adapters/secondary/database-adapter'; 4 | 5 | // this is the repository which the domain calls to utilise the adapter 6 | // only working with domain entities, and translating dto's from the secondary adapters 7 | // domain --> (repository) --> adapter (always returns a domain object) 8 | export async function createCustomerAccount( 9 | account: CustomerAccount 10 | ): Promise { 11 | // use the adapter to call the database 12 | const customerAccount: CustomerAccountDto = await createAccount( 13 | account.toDto() 14 | ); 15 | return CustomerAccount.toDomain(customerAccount); 16 | } 17 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/create-customer-account-repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-customer-account-repository'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/publish-event-recipient/index.ts: -------------------------------------------------------------------------------- 1 | export * from './publish-event-recipient'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/publish-event-recipient/publish-event-recipient.test.ts: -------------------------------------------------------------------------------- 1 | import * as publishEvent from '@adapters/secondary/event-adapter/event-adapter'; 2 | 3 | import { CustomerAccount } from '@domain/customer-account'; 4 | import { CustomerAddressProps } from '@models/customer-address-types'; 5 | import { NewCustomerAccountProps } from '@models/customer-account-types'; 6 | import { publishDomainEvents } from '@repositories/publish-event-recipient/publish-event-recipient'; 7 | 8 | describe('publish-event-repository', () => { 9 | afterAll(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | it('should return void on success', async () => { 14 | // arrange 15 | const customerAddress: CustomerAddressProps = { 16 | addressLineOne: 'line one', 17 | postCode: 'ne11bb', 18 | }; 19 | const newCustomer: NewCustomerAccountProps = { 20 | firstName: 'Lee', 21 | surname: 'Gilmore', 22 | customerAddress, 23 | }; 24 | 25 | const customer: CustomerAccount = 26 | CustomerAccount.createAccount(newCustomer); 27 | 28 | jest.spyOn(publishEvent, 'publishEvent').mockReturnThis(); 29 | 30 | await expect( 31 | publishDomainEvents(customer.retrieveDomainEvents()) 32 | ).resolves.toBeUndefined(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/publish-event-recipient/publish-event-recipient.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '@entity/domain-event'; 2 | import { publishEvent } from '@adapters/secondary/event-adapter'; 3 | 4 | // this is the repository which the domain calls to utilise the adapter 5 | // only working with domain entities, and translating dto's from the secondary adapters 6 | // domain --> (repository) --> adapter 7 | export async function publishDomainEvents( 8 | events: DomainEvent[] 9 | ): Promise { 10 | const eventsToPublish: Promise[] = events.map((item) => 11 | publishEvent( 12 | item.event, 13 | item.eventName, 14 | item.source, 15 | item.eventVersion, 16 | item.eventDateTime 17 | ) 18 | ); 19 | await Promise.all(eventsToPublish); 20 | } 21 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/retrieve-customer-account-repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from './retrieve-customer-account-repository'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/retrieve-customer-account-repository/retrieve-customer-account-repository.test.ts: -------------------------------------------------------------------------------- 1 | import * as retrieveAccount from '@adapters/secondary/database-adapter/database-adapter'; 2 | 3 | import { 4 | PaymentStatus, 5 | SubscriptionType, 6 | } from '@models/customer-account-types'; 7 | 8 | import { CustomerAccountDto } from '@dto/customer-account'; 9 | import { retrieveCustomerAccount } from '@repositories/retrieve-customer-account-repository/retrieve-customer-account-repository'; 10 | 11 | describe('retrieve-customer-account-repository', () => { 12 | afterAll(() => { 13 | jest.clearAllMocks(); 14 | }); 15 | 16 | it('should return the correct customer domain object', async () => { 17 | // arrange 18 | const customerAccountProps: CustomerAccountDto = { 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 | const customerId = '111'; 38 | 39 | jest 40 | .spyOn(retrieveAccount, 'retrieveAccount') 41 | .mockResolvedValue(customerAccountProps); 42 | 43 | await expect(retrieveCustomerAccount(customerId)).resolves. 44 | toMatchInlineSnapshot(` 45 | CustomerAccount { 46 | "_created": "created", 47 | "_domainEvents": Array [], 48 | "_id": "111", 49 | "_updated": "updated", 50 | "props": Object { 51 | "created": "created", 52 | "customerAddress": CustomerAddress { 53 | "props": Object { 54 | "addressLineFive": "line five", 55 | "addressLineFour": "line four", 56 | "addressLineOne": "line one", 57 | "addressLineThree": "line three", 58 | "addressLineTwo": "line two", 59 | "postCode": "ne11bb", 60 | }, 61 | }, 62 | "firstName": "Gilmore", 63 | "id": "111", 64 | "paymentStatus": "Valid", 65 | "playlists": Array [], 66 | "subscriptionType": "Basic", 67 | "surname": "Lee", 68 | "updated": "updated", 69 | }, 70 | } 71 | `); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/retrieve-customer-account-repository/retrieve-customer-account-repository.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAccount } from '@domain/customer-account/customer-account'; 2 | import { CustomerAccountDto } from '@dto/customer-account'; 3 | import { retrieveAccount } from '@adapters/secondary/database-adapter'; 4 | 5 | // this is the repository which the domain calls to utilise the adapter 6 | // only working with domain entities, and translating dto's from the secondary adapters 7 | // domain --> (repository) --> adapter (always returns a domain object) 8 | export async function retrieveCustomerAccount( 9 | id: string 10 | ): Promise { 11 | // use the adapter to call the database 12 | const customerAccount: CustomerAccountDto = await retrieveAccount(id); 13 | return CustomerAccount.toDomain(customerAccount); 14 | } 15 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/update-customer-account-repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from './update-customer-account-repository'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/update-customer-account-repository/update-customer-account-repository.test.ts: -------------------------------------------------------------------------------- 1 | import * as updateAccount from '@adapters/secondary/database-adapter/database-adapter'; 2 | 3 | import { 4 | PaymentStatus, 5 | SubscriptionType, 6 | } from '@models/customer-account-types'; 7 | 8 | import { CustomerAccount } from '@domain/customer-account'; 9 | import { CustomerAccountDto } from '@dto/customer-account'; 10 | import { updateCustomerAccount } from '@repositories/update-customer-account-repository/update-customer-account-repository'; 11 | 12 | describe('update-customer-account-repository', () => { 13 | afterAll(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | it('should return the correct customer domain object', async () => { 18 | // arrange 19 | const customerAccountProps: CustomerAccountDto = { 20 | id: '111', 21 | firstName: 'Gilmore', 22 | surname: 'Lee', 23 | subscriptionType: SubscriptionType.Basic, 24 | paymentStatus: PaymentStatus.Valid, 25 | created: 'created', 26 | updated: 'updated', 27 | playlists: [], 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 | 38 | const customer: CustomerAccount = 39 | CustomerAccount.createAccount(customerAccountProps); 40 | 41 | jest 42 | .spyOn(updateAccount, 'updateAccount') 43 | .mockResolvedValue(customerAccountProps); 44 | 45 | await expect(updateCustomerAccount(customer)).resolves. 46 | toMatchInlineSnapshot(` 47 | CustomerAccount { 48 | "_created": "created", 49 | "_domainEvents": Array [], 50 | "_id": "111", 51 | "_updated": "updated", 52 | "props": Object { 53 | "created": "created", 54 | "customerAddress": CustomerAddress { 55 | "props": Object { 56 | "addressLineFive": "line five", 57 | "addressLineFour": "line four", 58 | "addressLineOne": "line one", 59 | "addressLineThree": "line three", 60 | "addressLineTwo": "line two", 61 | "postCode": "ne11bb", 62 | }, 63 | }, 64 | "firstName": "Gilmore", 65 | "id": "111", 66 | "paymentStatus": "Valid", 67 | "playlists": Array [], 68 | "subscriptionType": "Basic", 69 | "surname": "Lee", 70 | "updated": "updated", 71 | }, 72 | } 73 | `); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/repositories/update-customer-account-repository/update-customer-account-repository.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAccount } from '@domain/customer-account/customer-account'; 2 | import { CustomerAccountDto } from '@dto/customer-account'; 3 | import { updateAccount } from '@adapters/secondary/database-adapter'; 4 | 5 | // this is the repository which the domain calls to utilise the adapter 6 | // only working with domain entities, and translating dto's from the secondary adapters 7 | // domain --> (repository) --> adapter (always returns a domain object) 8 | export async function updateCustomerAccount( 9 | account: CustomerAccount 10 | ): Promise { 11 | // use the adapter to call the database 12 | const customerAccount: CustomerAccountDto = await updateAccount( 13 | account.toDto() 14 | ); 15 | return CustomerAccount.toDomain(customerAccount); 16 | } 17 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-account.schema.test.ts: -------------------------------------------------------------------------------- 1 | import { PaymentStatus, SubscriptionType } from '../models'; 2 | 3 | import { CustomerAccountDto } from '@dto/customer-account'; 4 | import { CustomerPlaylistDto } from '@dto/customer-playlist'; 5 | import { schema } from './customer-account.schema'; 6 | import { schemaValidator } from '../../../packages/schema-validator'; 7 | 8 | let playlists: CustomerPlaylistDto[] = [ 9 | { 10 | created: '2022-01-01T00:00:00.000Z', 11 | updated: '2022-01-01T00:00:00.000Z', 12 | songIds: ['123', '234'], 13 | id: 'a59e49ad-8f88-448f-8a15-41d560ad6d70', 14 | playlistName: 'testplaylist', 15 | }, 16 | ]; 17 | 18 | let body: CustomerAccountDto = { 19 | id: 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 20 | firstName: 'Lee', 21 | surname: 'Gilmore', 22 | paymentStatus: PaymentStatus.Valid, 23 | subscriptionType: SubscriptionType.Upgraded, 24 | playlists, 25 | created: '2022-01-01T00:00:00.000Z', 26 | updated: '2022-01-01T00:00:00.000Z', 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 | describe('customer-account-schema', () => { 38 | it('should validate correctly with the correct payload', () => { 39 | expect(() => schemaValidator(schema, body)).not.toThrow(); 40 | }); 41 | 42 | it('should throw an error when there are more than 9 properties', () => { 43 | const badBody = { 44 | ...body, 45 | additionalProp: 'tree', 46 | }; 47 | expect(() => 48 | schemaValidator(schema, badBody) 49 | ).toThrowErrorMatchingInlineSnapshot( 50 | `"[{\\"instancePath\\":\\"\\",\\"schemaPath\\":\\"#/maxProperties\\",\\"keyword\\":\\"maxProperties\\",\\"params\\":{\\"limit\\":9},\\"message\\":\\"must NOT have more than 9 properties\\"}]"` 51 | ); 52 | }); 53 | 54 | it('should throw an error when there are less than 9 properties', () => { 55 | const badBody = {}; 56 | expect(() => 57 | schemaValidator(schema, badBody) 58 | ).toThrowErrorMatchingInlineSnapshot( 59 | `"[{\\"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'\\"}]"` 60 | ); 61 | }); 62 | 63 | it('should throw an error if id is not valid', () => { 64 | const badBody = { 65 | ...body, 66 | id: 111, // not a string 67 | }; 68 | expect(() => 69 | schemaValidator(schema, badBody) 70 | ).toThrowErrorMatchingInlineSnapshot( 71 | `"[{\\"instancePath\\":\\"/id\\",\\"schemaPath\\":\\"#/properties/id/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 72 | ); 73 | }); 74 | 75 | it('should throw an error if firstName is not valid', () => { 76 | const badBody = { 77 | ...body, 78 | firstName: '!@$%*', // not valid 79 | }; 80 | expect(() => 81 | schemaValidator(schema, badBody) 82 | ).toThrowErrorMatchingInlineSnapshot( 83 | `"[{\\"instancePath\\":\\"/firstName\\",\\"schemaPath\\":\\"#/properties/firstName/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 84 | ); 85 | }); 86 | 87 | it('should throw an error if surname is not valid', () => { 88 | const badBody = { 89 | ...body, 90 | surname: '!@$%*', // not valid 91 | }; 92 | expect(() => 93 | schemaValidator(schema, badBody) 94 | ).toThrowErrorMatchingInlineSnapshot( 95 | `"[{\\"instancePath\\":\\"/surname\\",\\"schemaPath\\":\\"#/properties/surname/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z]+$\\\\\\"\\"}]"` 96 | ); 97 | }); 98 | 99 | it('should throw an error if paymentStatus is not valid', () => { 100 | const badBody = { 101 | ...body, 102 | paymentStatus: 'Tree', // not valid 103 | }; 104 | expect(() => 105 | schemaValidator(schema, badBody) 106 | ).toThrowErrorMatchingInlineSnapshot( 107 | `"[{\\"instancePath\\":\\"/paymentStatus\\",\\"schemaPath\\":\\"#/properties/paymentStatus/enum\\",\\"keyword\\":\\"enum\\",\\"params\\":{\\"allowedValues\\":[\\"Valid\\",\\"Invalid\\"]},\\"message\\":\\"must be equal to one of the allowed values\\"}]"` 108 | ); 109 | }); 110 | 111 | it('should throw an error if subscriptionType is not valid', () => { 112 | const badBody = { 113 | ...body, 114 | subscriptionType: 'Tree', // not valid 115 | }; 116 | expect(() => 117 | schemaValidator(schema, badBody) 118 | ).toThrowErrorMatchingInlineSnapshot( 119 | `"[{\\"instancePath\\":\\"/subscriptionType\\",\\"schemaPath\\":\\"#/properties/subscriptionType/enum\\",\\"keyword\\":\\"enum\\",\\"params\\":{\\"allowedValues\\":[\\"Basic\\",\\"Upgraded\\"]},\\"message\\":\\"must be equal to one of the allowed values\\"}]"` 120 | ); 121 | }); 122 | 123 | it('should throw an error if created is not valid', () => { 124 | const badBody = { 125 | ...body, 126 | created: 111, // not a string 127 | }; 128 | expect(() => 129 | schemaValidator(schema, badBody) 130 | ).toThrowErrorMatchingInlineSnapshot( 131 | `"[{\\"instancePath\\":\\"/created\\",\\"schemaPath\\":\\"#/properties/created/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 132 | ); 133 | }); 134 | 135 | it('should throw an error if updated is not valid', () => { 136 | const badBody = { 137 | ...body, 138 | updated: 111, // not a string 139 | }; 140 | expect(() => 141 | schemaValidator(schema, badBody) 142 | ).toThrowErrorMatchingInlineSnapshot( 143 | `"[{\\"instancePath\\":\\"/updated\\",\\"schemaPath\\":\\"#/properties/updated/type\\",\\"keyword\\":\\"type\\",\\"params\\":{\\"type\\":\\"string\\"},\\"message\\":\\"must be string\\"}]"` 144 | ); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-account.schema.ts: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | type: 'object', 3 | required: [ 4 | 'id', 5 | 'firstName', 6 | 'surname', 7 | 'paymentStatus', 8 | 'subscriptionType', 9 | 'playlists', 10 | 'created', 11 | 'updated', 12 | 'customerAddress', 13 | ], 14 | maxProperties: 9, 15 | minProperties: 9, 16 | properties: { 17 | id: { 18 | type: 'string', 19 | }, 20 | firstName: { 21 | type: 'string', 22 | pattern: '^[a-zA-Z]+$', 23 | }, 24 | surname: { 25 | type: 'string', 26 | pattern: '^[a-zA-Z]+$', 27 | }, 28 | paymentStatus: { 29 | type: 'string', 30 | enum: ['Valid', 'Invalid'], 31 | }, 32 | subscriptionType: { 33 | type: 'string', 34 | enum: ['Basic', 'Upgraded'], 35 | }, 36 | created: { 37 | type: 'string', 38 | }, 39 | updated: { 40 | type: 'string', 41 | }, 42 | playlists: { 43 | type: 'array', 44 | items: { 45 | type: 'object', 46 | }, 47 | }, 48 | customerAddress: { 49 | type: 'object', 50 | }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-address.test.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAddressProps } from '@models/customer-address-types'; 2 | import { schema } from './customer-address'; 3 | import { schemaValidator } from '../../../packages/schema-validator'; 4 | 5 | let body: CustomerAddressProps = { 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(`"[{\\"instancePath\\":\\"/addressLineOne\\",\\"schemaPath\\":\\"#/properties/addressLineOne/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"`); 39 | }); 40 | 41 | it('should throw an error when address line two is invalid', () => { 42 | const badBody = { 43 | ...body, 44 | addressLineTwo: '±', 45 | }; 46 | expect(() => 47 | schemaValidator(schema, badBody)). 48 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/addressLineTwo\\",\\"schemaPath\\":\\"#/properties/addressLineTwo/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"`); 49 | }); 50 | 51 | it('should throw an error when address line three is invalid', () => { 52 | const badBody = { 53 | ...body, 54 | addressLineThree: '±', 55 | }; 56 | expect(() => 57 | schemaValidator(schema, badBody)). 58 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/addressLineThree\\",\\"schemaPath\\":\\"#/properties/addressLineThree/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"`); 59 | }); 60 | 61 | it('should throw an error when address line four is invalid', () => { 62 | const badBody = { 63 | ...body, 64 | addressLineFour: '±', 65 | }; 66 | expect(() => 67 | schemaValidator(schema, badBody)). 68 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/addressLineFour\\",\\"schemaPath\\":\\"#/properties/addressLineFour/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"`); 69 | }); 70 | 71 | it('should throw an error when address line five is invalid', () => { 72 | const badBody = { 73 | ...body, 74 | addressLineFive: '±', 75 | }; 76 | expect(() => 77 | schemaValidator(schema, badBody)). 78 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/addressLineFive\\",\\"schemaPath\\":\\"#/properties/addressLineFive/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"`); 79 | }); 80 | 81 | it('should throw an error when postcode is invalid', () => { 82 | const badBody = { 83 | ...body, 84 | postCode: '±', 85 | }; 86 | expect(() => 87 | schemaValidator(schema, badBody)). 88 | toThrowErrorMatchingInlineSnapshot(`"[{\\"instancePath\\":\\"/postCode\\",\\"schemaPath\\":\\"#/properties/postCode/pattern\\",\\"keyword\\":\\"pattern\\",\\"params\\":{\\"pattern\\":\\"^[a-zA-Z0-9 _.-]+$\\"},\\"message\\":\\"must match pattern \\\\\\"^[a-zA-Z0-9 _.-]+$\\\\\\"\\"}]"`); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/schemas/customer-address.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/use-cases/add-song-to-playlist/add-song-to-playlist.test.ts: -------------------------------------------------------------------------------- 1 | import * as publishDomainEvents from '@repositories/publish-event-recipient/publish-event-recipient'; 2 | import * as retrieveCustomerAccount from '@repositories/retrieve-customer-account-repository/retrieve-customer-account-repository'; 3 | import * as updateCustomerAccount from '@repositories/update-customer-account-repository/update-customer-account-repository'; 4 | 5 | import { 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@models/customer-account-types'; 9 | 10 | import { CustomerAccount } from '@domain/customer-account'; 11 | import { CustomerAccountDto } from '@dto/customer-account'; 12 | import { NewCustomerPlaylistSongDto } from '@dto/customer-playlist'; 13 | import { addSongToPlaylistUseCase } from '@use-cases/add-song-to-playlist/add-song-to-playlist'; 14 | 15 | let customerAccountDto: CustomerAccountDto; 16 | let newCustomerPlaylistSongDto: NewCustomerPlaylistSongDto; 17 | 18 | describe('add-song-to-playlist-use-case', () => { 19 | beforeAll(() => { 20 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 21 | }); 22 | 23 | afterAll(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | beforeEach(() => { 28 | customerAccountDto = { 29 | id: '111', 30 | firstName: 'Gilmore', 31 | surname: 'Lee', 32 | subscriptionType: SubscriptionType.Basic, 33 | paymentStatus: PaymentStatus.Valid, 34 | created: 'created', 35 | updated: 'updated', 36 | playlists: [], 37 | customerAddress: { 38 | addressLineOne: 'line one', 39 | addressLineTwo: 'line two', 40 | addressLineThree: 'line three', 41 | addressLineFour: 'line four', 42 | addressLineFive: 'line five', 43 | postCode: 'ne11bb', 44 | }, 45 | }; 46 | newCustomerPlaylistSongDto = { 47 | songId: 'songone', 48 | }; 49 | 50 | const createdAccount: CustomerAccount = 51 | CustomerAccount.createAccount(customerAccountDto); 52 | 53 | createdAccount.createPlaylist('playlistOne'); 54 | 55 | jest 56 | .spyOn(retrieveCustomerAccount, 'retrieveCustomerAccount') 57 | .mockResolvedValue(createdAccount); 58 | 59 | jest 60 | .spyOn(updateCustomerAccount, 'updateCustomerAccount') 61 | .mockResolvedValue(createdAccount); 62 | 63 | jest.spyOn(publishDomainEvents, 'publishDomainEvents').mockResolvedValue(); 64 | }); 65 | 66 | it('should return the correct dto on success', async () => { 67 | // act 68 | const response = await addSongToPlaylistUseCase( 69 | '111', 70 | 'f39e49ad-8f88-448f-8a15-41d560ad6d70', 71 | newCustomerPlaylistSongDto 72 | ); 73 | // arrange / assert 74 | expect(response).toMatchInlineSnapshot(` 75 | Object { 76 | "created": "2022-01-01T00:00:00.000Z", 77 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 78 | "playlistName": "playlistOne", 79 | "songIds": Array [ 80 | "songone", 81 | ], 82 | "updated": "2022-01-01T00:00:00.000Z", 83 | } 84 | `); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/add-song-to-playlist/add-song-to-playlist.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomerPlaylistDto, 3 | NewCustomerPlaylistSongDto, 4 | } from '@dto/customer-playlist'; 5 | 6 | import { CustomerAccount } from '@domain/customer-account'; 7 | import { CustomerPlaylist } from '@domain/customer-playlist'; 8 | import { publishDomainEvents } from '@repositories/publish-event-recipient'; 9 | import { retrieveCustomerAccount } from '@repositories/retrieve-customer-account-repository'; 10 | import { updateCustomerAccount } from '@repositories/update-customer-account-repository'; 11 | 12 | // takes a dto and calls the domain entities (returning a dto to the primary adapter) 13 | // adapter --> (use case) --> domain & repositories 14 | 15 | /** 16 | * Adds a song to a Customer Playlist 17 | * Input: accountId, playlistId 18 | * Output: CustomerPlaylistDto 19 | * 20 | * Primary course: 21 | * 22 | * 1. Retrieve the customer account 23 | * 2. Add the song to the playlist 24 | * 3. Save the changes as a whole (aggregate root) 25 | * 4. Publish a SongAddedToPlaylist event. 26 | */ 27 | export async function addSongToPlaylistUseCase( 28 | accountId: string, 29 | playlistId: string, 30 | song: NewCustomerPlaylistSongDto 31 | ): Promise { 32 | const existingAccount: CustomerAccount = await retrieveCustomerAccount( 33 | accountId 34 | ); 35 | 36 | // get the playlist in question from the existing account 37 | const playlist: CustomerPlaylist = 38 | existingAccount.retrievePlaylist(playlistId); 39 | 40 | // add the song to the playlist 41 | playlist.addSongToPlaylist(song.songId); 42 | 43 | // persist the full aggregate root i.e. all entities (we can't update one on its own) 44 | await updateCustomerAccount(existingAccount); 45 | 46 | await publishDomainEvents(existingAccount.retrieveDomainEvents()); 47 | 48 | return playlist.toDto(); 49 | } 50 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-account/create-customer-account.test.ts: -------------------------------------------------------------------------------- 1 | import * as createCustomerAccount from '@repositories/create-customer-account-repository/create-customer-account-repository'; 2 | import * as publishDomainEvents from '@repositories/publish-event-recipient/publish-event-recipient'; 3 | 4 | import { 5 | PaymentStatus, 6 | SubscriptionType, 7 | } from '@models/customer-account-types'; 8 | 9 | import { CustomerAccount } from '@domain/customer-account'; 10 | import { CustomerAccountDto } from '@dto/customer-account'; 11 | import { createCustomerAccountUseCase } from '@use-cases/create-customer-account/create-customer-account'; 12 | 13 | let customerAccountDto: CustomerAccountDto; 14 | 15 | describe('create-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: 'Gilmore', 28 | surname: 'Lee', 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 | const createdAccount: CustomerAccount = 45 | CustomerAccount.createAccount(customerAccountDto); 46 | 47 | jest 48 | .spyOn(createCustomerAccount, 'createCustomerAccount') 49 | .mockResolvedValue(createdAccount); 50 | 51 | jest.spyOn(publishDomainEvents, 'publishDomainEvents').mockResolvedValue(); 52 | }); 53 | 54 | it('should return the correct dto on success', async () => { 55 | // act 56 | const response = await createCustomerAccountUseCase(customerAccountDto); 57 | // arrange / assert 58 | expect(response).toMatchInlineSnapshot(` 59 | Object { 60 | "created": "2022-01-01T00:00:00.000Z", 61 | "customerAddress": Object { 62 | "addressLineFive": "line five", 63 | "addressLineFour": "line four", 64 | "addressLineOne": "line one", 65 | "addressLineThree": "line three", 66 | "addressLineTwo": "line two", 67 | "postCode": "ne11bb", 68 | }, 69 | "firstName": "Gilmore", 70 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 71 | "paymentStatus": "Valid", 72 | "playlists": Array [], 73 | "subscriptionType": "Basic", 74 | "surname": "Lee", 75 | "updated": "2022-01-01T00:00:00.000Z", 76 | } 77 | `); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-account/create-customer-account.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomerAccountDto, 3 | NewCustomerAccountDto, 4 | } from '@dto/customer-account'; 5 | 6 | import { CustomerAccount } from '@domain/customer-account'; 7 | import { createCustomerAccount } from '@repositories/create-customer-account-repository'; 8 | import { logger } from '@packages/logger'; 9 | import { publishDomainEvents } from '@repositories/publish-event-recipient'; 10 | 11 | // takes a dto and calls the domain entities (returning a dto to the primary adapter) 12 | // adapter --> (use case) --> domain & repositories 13 | 14 | /** 15 | * Create a new Customer Account 16 | * Input: NewCustomerAccountDto 17 | * Output: CustomerAccountDto 18 | * 19 | * Primary course: 20 | * 21 | * 1. Validate the customer account details 22 | * 2. Create a new customer account 23 | * 3. Publish a CustomerAccountCreated event. 24 | */ 25 | export async function createCustomerAccountUseCase( 26 | account: NewCustomerAccountDto 27 | ): Promise { 28 | const newCustomer: CustomerAccount = CustomerAccount.createAccount(account); 29 | 30 | await createCustomerAccount(newCustomer); 31 | logger.info(`customer account created for ${newCustomer.id}`); 32 | 33 | await publishDomainEvents(newCustomer.retrieveDomainEvents()); 34 | 35 | return newCustomer.toDto(); 36 | } 37 | -------------------------------------------------------------------------------- /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 publishDomainEvents from '@repositories/publish-event-recipient/publish-event-recipient'; 2 | import * as retrieveCustomerAccount from '@repositories/retrieve-customer-account-repository/retrieve-customer-account-repository'; 3 | import * as updateCustomerAccount from '@repositories/update-customer-account-repository/update-customer-account-repository'; 4 | 5 | import { 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@models/customer-account-types'; 9 | 10 | import { CustomerAccount } from '@domain/customer-account'; 11 | import { CustomerAccountDto } from '@dto/customer-account'; 12 | import { NewCustomerPlaylistDto } from '@dto/customer-playlist'; 13 | import { createCustomerPlaylistUseCase } from '@use-cases/create-customer-playlist'; 14 | 15 | let newCustomerPlaylistDto: NewCustomerPlaylistDto; 16 | let customerAccountDto: CustomerAccountDto; 17 | 18 | describe('create-customer-playlist-use-case', () => { 19 | beforeAll(() => { 20 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 21 | }); 22 | 23 | afterAll(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | beforeEach(() => { 28 | newCustomerPlaylistDto = { 29 | playlistName: 'testplaylist', 30 | }; 31 | customerAccountDto = { 32 | id: '111', 33 | firstName: 'Gilmore', 34 | surname: 'Lee', 35 | subscriptionType: SubscriptionType.Basic, 36 | paymentStatus: PaymentStatus.Valid, 37 | created: 'created', 38 | updated: 'updated', 39 | playlists: [], 40 | customerAddress: { 41 | addressLineOne: 'line one', 42 | addressLineTwo: 'line two', 43 | addressLineThree: 'line three', 44 | addressLineFour: 'line four', 45 | addressLineFive: 'line five', 46 | postCode: 'ne11bb', 47 | }, 48 | }; 49 | 50 | const createdAccount: CustomerAccount = 51 | CustomerAccount.createAccount(customerAccountDto); 52 | 53 | jest 54 | .spyOn(retrieveCustomerAccount, 'retrieveCustomerAccount') 55 | .mockResolvedValue(createdAccount); 56 | 57 | jest 58 | .spyOn(updateCustomerAccount, 'updateCustomerAccount') 59 | .mockResolvedValue(createdAccount); 60 | 61 | jest.spyOn(publishDomainEvents, 'publishDomainEvents').mockResolvedValue(); 62 | }); 63 | 64 | it('should return the correct dto on success', async () => { 65 | // act 66 | const response = await createCustomerPlaylistUseCase( 67 | '111', 68 | newCustomerPlaylistDto 69 | ); 70 | // arrange / assert 71 | expect(response).toMatchInlineSnapshot(` 72 | Object { 73 | "created": "2022-01-01T00:00:00.000Z", 74 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 75 | "playlistName": "testplaylist", 76 | "songIds": Array [], 77 | "updated": "2022-01-01T00:00:00.000Z", 78 | } 79 | `); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-playlist/create-customer-playlist.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomerPlaylistDto, 3 | NewCustomerPlaylistDto, 4 | } from '@dto/customer-playlist'; 5 | 6 | import { CustomerAccount } from '@domain/customer-account'; 7 | import { CustomerPlaylist } from '@domain/customer-playlist'; 8 | import { logger } from '@packages/logger'; 9 | import { publishDomainEvents } from '@repositories/publish-event-recipient'; 10 | import { retrieveCustomerAccount } from '@repositories/retrieve-customer-account-repository'; 11 | import { updateCustomerAccount } from '@repositories/update-customer-account-repository'; 12 | 13 | // takes a dto and calls the domain entities (returning a dto to the primary adapter) 14 | // adapter --> (use case) --> domain & repositories 15 | 16 | /** 17 | * Create a new Customer Playlist 18 | * Input: accountId, playlist 19 | * Output: CustomerPlaylistDto 20 | * 21 | * Primary course: 22 | * 23 | * 1. Retrieve the customer account 24 | * 2. Create and add the new customer playlist to the account 25 | * 3. Save the changes as a whole (aggregate root) 26 | * 4. Publish a CustomerPlaylistCreated event. 27 | */ 28 | export async function createCustomerPlaylistUseCase( 29 | accountId: string, 30 | playlist: NewCustomerPlaylistDto 31 | ): Promise { 32 | const existingAccount: CustomerAccount = await retrieveCustomerAccount( 33 | accountId 34 | ); 35 | 36 | const newPlaylist: CustomerPlaylist = existingAccount.createPlaylist( 37 | playlist.playlistName 38 | ); 39 | 40 | // persist the full aggregate root i.e. both entities (we can't update one on its own) 41 | await updateCustomerAccount(existingAccount); 42 | 43 | logger.info( 44 | `customer playlist ${newPlaylist.id} created for ${existingAccount.id}` 45 | ); 46 | 47 | await publishDomainEvents(existingAccount.retrieveDomainEvents()); 48 | 49 | return newPlaylist.toDto(); 50 | } 51 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/create-customer-playlist/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-customer-playlist'; 2 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-customer-account/create-customer-account'; 2 | export * from './retrieve-customer-account/retrieve-customer-account'; 3 | export * from './upgrade-customer-account/upgrade-customer-account'; 4 | -------------------------------------------------------------------------------- /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 retrieveCustomerAccount from '@repositories/retrieve-customer-account-repository/retrieve-customer-account-repository'; 2 | 3 | import { 4 | PaymentStatus, 5 | SubscriptionType, 6 | } from '@models/customer-account-types'; 7 | 8 | import { CustomerAccount } from '@domain/customer-account'; 9 | import { CustomerAccountDto } from '@dto/customer-account'; 10 | import { retrieveCustomerAccountUseCase } from '@use-cases/retrieve-customer-account/retrieve-customer-account'; 11 | 12 | let customerAccountDto: CustomerAccountDto; 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: 'Gilmore', 27 | surname: 'Lee', 28 | subscriptionType: SubscriptionType.Basic, 29 | paymentStatus: PaymentStatus.Valid, 30 | created: 'created', 31 | updated: 'updated', 32 | playlists: [], 33 | customerAddress: { 34 | addressLineOne: 'line one', 35 | addressLineTwo: 'line two', 36 | addressLineThree: 'line three', 37 | addressLineFour: 'line four', 38 | addressLineFive: 'line five', 39 | postCode: 'ne11bb', 40 | }, 41 | }; 42 | 43 | const createdAccount: CustomerAccount = 44 | CustomerAccount.createAccount(customerAccountDto); 45 | 46 | jest 47 | .spyOn(retrieveCustomerAccount, 'retrieveCustomerAccount') 48 | .mockResolvedValue(createdAccount); 49 | }); 50 | 51 | it('should return the correct dto on success', async () => { 52 | // arrange 53 | const customerId = '111'; 54 | const response = await retrieveCustomerAccountUseCase(customerId); 55 | 56 | // act / assert 57 | expect(response).toMatchInlineSnapshot(` 58 | Object { 59 | "created": "2022-01-01T00:00:00.000Z", 60 | "customerAddress": Object { 61 | "addressLineFive": "line five", 62 | "addressLineFour": "line four", 63 | "addressLineOne": "line one", 64 | "addressLineThree": "line three", 65 | "addressLineTwo": "line two", 66 | "postCode": "ne11bb", 67 | }, 68 | "firstName": "Gilmore", 69 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 70 | "paymentStatus": "Valid", 71 | "playlists": Array [], 72 | "subscriptionType": "Basic", 73 | "surname": "Lee", 74 | "updated": "2022-01-01T00:00:00.000Z", 75 | } 76 | `); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/retrieve-customer-account/retrieve-customer-account.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAccount } from '@domain/customer-account'; 2 | import { CustomerAccountDto } from '@dto/customer-account'; 3 | import { logger } from '@packages/logger'; 4 | import { retrieveCustomerAccount } from '@repositories/retrieve-customer-account-repository'; 5 | 6 | // takes a dto and calls the domain entities (returning a dto to the primary adapter) 7 | // adapter --> (use case) --> repositories 8 | 9 | /** 10 | * Retrueve 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 instance: CustomerAccount = await retrieveCustomerAccount(id); 22 | 23 | logger.info(`retrieved customer account for ${id}`); 24 | 25 | return instance.toDto(); 26 | } 27 | -------------------------------------------------------------------------------- /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 publishDomainEvents from '@repositories/publish-event-recipient/publish-event-recipient'; 2 | import * as retrieveCustomerAccount from '@repositories/retrieve-customer-account-repository/retrieve-customer-account-repository'; 3 | import * as updateCustomerAccount from '@repositories/update-customer-account-repository/update-customer-account-repository'; 4 | 5 | import { 6 | PaymentStatus, 7 | SubscriptionType, 8 | } from '@models/customer-account-types'; 9 | 10 | import { CustomerAccount } from '@domain/customer-account'; 11 | import { CustomerAccountDto } from '@dto/customer-account'; 12 | import { upgradeCustomerAccountUseCase } from '@use-cases/upgrade-customer-account/upgrade-customer-account'; 13 | 14 | let customerAccountDto: CustomerAccountDto; 15 | 16 | describe('upgrade-customer-use-case', () => { 17 | beforeAll(() => { 18 | jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); 19 | }); 20 | 21 | afterAll(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | beforeEach(() => { 26 | customerAccountDto = { 27 | id: '111', 28 | firstName: 'Gilmore', 29 | surname: 'Lee', 30 | subscriptionType: SubscriptionType.Basic, 31 | paymentStatus: PaymentStatus.Valid, 32 | created: 'created', 33 | updated: 'updated', 34 | playlists: [], 35 | customerAddress: { 36 | addressLineOne: 'line one', 37 | addressLineTwo: 'line two', 38 | addressLineThree: 'line three', 39 | addressLineFour: 'line four', 40 | addressLineFive: 'line five', 41 | postCode: 'ne11bb', 42 | }, 43 | }; 44 | 45 | const createdAccount: CustomerAccount = 46 | CustomerAccount.createAccount(customerAccountDto); 47 | 48 | jest 49 | .spyOn(retrieveCustomerAccount, 'retrieveCustomerAccount') 50 | .mockResolvedValue(createdAccount); 51 | jest 52 | .spyOn(updateCustomerAccount, 'updateCustomerAccount') 53 | .mockResolvedValue(createdAccount); 54 | 55 | jest.spyOn(publishDomainEvents, 'publishDomainEvents').mockResolvedValue(); 56 | }); 57 | 58 | it('should update the subscription type on success', async () => { 59 | // arrange 60 | const customerId = '111'; 61 | const response = await upgradeCustomerAccountUseCase(customerId); 62 | 63 | // act / assert 64 | expect(response).toMatchInlineSnapshot(` 65 | Object { 66 | "created": "2022-01-01T00:00:00.000Z", 67 | "customerAddress": Object { 68 | "addressLineFive": "line five", 69 | "addressLineFour": "line four", 70 | "addressLineOne": "line one", 71 | "addressLineThree": "line three", 72 | "addressLineTwo": "line two", 73 | "postCode": "ne11bb", 74 | }, 75 | "firstName": "Gilmore", 76 | "id": "f39e49ad-8f88-448f-8a15-41d560ad6d70", 77 | "paymentStatus": "Valid", 78 | "playlists": Array [], 79 | "subscriptionType": "Upgraded", 80 | "surname": "Lee", 81 | "updated": "2022-01-01T00:00:00.000Z", 82 | } 83 | `); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /onion-sounds/stateless/src/use-cases/upgrade-customer-account/upgrade-customer-account.ts: -------------------------------------------------------------------------------- 1 | import { CustomerAccount } from '@domain/customer-account'; 2 | import { CustomerAccountDto } from '@dto/customer-account'; 3 | import { logger } from '@packages/logger'; 4 | import { publishDomainEvents } from '@repositories/publish-event-recipient'; 5 | import { retrieveCustomerAccount } from '@repositories/retrieve-customer-account-repository'; 6 | import { updateCustomerAccount } from '@repositories/update-customer-account-repository'; 7 | 8 | // takes a dto and calls the domain entities (returning a dto to the primary adapter) 9 | // adapter --> (use case) --> repositories 10 | 11 | /** 12 | * Upgrade an existing Customer Account 13 | * Input: Customer account ID 14 | * Output: CustomerAccountDto 15 | * 16 | * Primary course: 17 | * 18 | * 1. Retrieve the customer account based on ID 19 | * 2. Upgrade and validate the customer account 20 | * 3. Publish a CustomerAccountUpdated event. 21 | */ 22 | export async function upgradeCustomerAccountUseCase( 23 | id: string 24 | ): Promise { 25 | const customerAccount: CustomerAccount = await retrieveCustomerAccount(id); 26 | 27 | customerAccount.upgradeAccount(); 28 | 29 | await updateCustomerAccount(customerAccount); 30 | 31 | logger.info(`customer account ${id} upgraded`); 32 | 33 | await publishDomainEvents(customerAccount.retrieveDomainEvents()); 34 | 35 | return customerAccount.toDto(); 36 | } 37 | -------------------------------------------------------------------------------- /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: '0.5', 31 | POWERTOOLS_TRACE_ENABLED: 'enabled', 32 | POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'captureHTTPsRequests', 33 | POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'captureResult', 34 | POWERTOOLS_METRICS_NAMESPACE: 'OnionSounds', 35 | }; 36 | 37 | const createAccountLambda: nodeLambda.NodejsFunction = 38 | new nodeLambda.NodejsFunction(this, 'CreateAccountLambda', { 39 | runtime: lambda.Runtime.NODEJS_16_X, 40 | entry: path.join( 41 | __dirname, 42 | 'src/adapters/primary/create-customer-account/create-customer-account.adapter.ts' 43 | ), 44 | memorySize: 1024, 45 | handler: 'handler', 46 | tracing: Tracing.ACTIVE, 47 | bundling: { 48 | minify: true, 49 | externalModules: ['aws-sdk'], 50 | }, 51 | environment: { 52 | TABLE_NAME: this.accountsTable.tableName, 53 | EVENT_BUS: this.accountsEventBus.eventBusArn, 54 | POWERTOOLS_SERVICE_NAME: 'CreateAccountLambda', 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 | POWERTOOLS_SERVICE_NAME: 'RetrieveAccountLambda', 77 | ...lambdaPowerToolsConfig, 78 | }, 79 | }); 80 | 81 | const upgradeAccountLambda: nodeLambda.NodejsFunction = 82 | new nodeLambda.NodejsFunction(this, 'UpgradeAccountLambda', { 83 | runtime: lambda.Runtime.NODEJS_16_X, 84 | entry: path.join( 85 | __dirname, 86 | 'src/adapters/primary/upgrade-customer-account/upgrade-customer-account.adapter.ts' 87 | ), 88 | memorySize: 1024, 89 | tracing: Tracing.ACTIVE, 90 | handler: 'handler', 91 | bundling: { 92 | minify: true, 93 | externalModules: ['aws-sdk'], 94 | }, 95 | environment: { 96 | TABLE_NAME: this.accountsTable.tableName, 97 | EVENT_BUS: this.accountsEventBus.eventBusArn, 98 | POWERTOOLS_SERVICE_NAME: 'UpgradeAccountLambda', 99 | ...lambdaPowerToolsConfig, 100 | }, 101 | }); 102 | 103 | const createPlaylistLambda: nodeLambda.NodejsFunction = 104 | new nodeLambda.NodejsFunction(this, 'CreatePlaylistLambda', { 105 | runtime: lambda.Runtime.NODEJS_16_X, 106 | entry: path.join( 107 | __dirname, 108 | 'src/adapters/primary/create-customer-playlist/create-customer-playlist.adpater.ts' 109 | ), 110 | memorySize: 1024, 111 | handler: 'handler', 112 | tracing: Tracing.ACTIVE, 113 | bundling: { 114 | minify: true, 115 | externalModules: ['aws-sdk'], 116 | }, 117 | environment: { 118 | TABLE_NAME: this.accountsTable.tableName, 119 | EVENT_BUS: this.accountsEventBus.eventBusArn, 120 | POWERTOOLS_SERVICE_NAME: 'CreatePlaylistLambda', 121 | ...lambdaPowerToolsConfig, 122 | }, 123 | }); 124 | 125 | const addSongToPlaylistLambda: nodeLambda.NodejsFunction = 126 | new nodeLambda.NodejsFunction(this, 'AddSongToPlaylistLambda', { 127 | runtime: lambda.Runtime.NODEJS_16_X, 128 | entry: path.join( 129 | __dirname, 130 | 'src/adapters/primary/add-song-to-playlist/add-song-to-playlist.adapter.ts' 131 | ), 132 | memorySize: 1024, 133 | handler: 'handler', 134 | tracing: Tracing.ACTIVE, 135 | bundling: { 136 | minify: true, 137 | externalModules: ['aws-sdk'], 138 | }, 139 | environment: { 140 | TABLE_NAME: this.accountsTable.tableName, 141 | EVENT_BUS: this.accountsEventBus.eventBusArn, 142 | POWERTOOLS_SERVICE_NAME: 'AddSongToPlaylistLambda', 143 | ...lambdaPowerToolsConfig, 144 | }, 145 | }); 146 | 147 | this.accountsTable.grantWriteData(createAccountLambda); 148 | this.accountsTable.grantReadData(retrieveAccountLambda); 149 | this.accountsTable.grantReadWriteData(upgradeAccountLambda); 150 | this.accountsTable.grantReadWriteData(createPlaylistLambda); 151 | this.accountsTable.grantReadWriteData(addSongToPlaylistLambda); 152 | 153 | this.accountsEventBus.grantPutEventsTo(createAccountLambda); 154 | this.accountsEventBus.grantPutEventsTo(upgradeAccountLambda); 155 | this.accountsEventBus.grantPutEventsTo(createPlaylistLambda); 156 | this.accountsEventBus.grantPutEventsTo(addSongToPlaylistLambda); 157 | 158 | const accountsApi: apigw.RestApi = new apigw.RestApi(this, 'AccountsApi', { 159 | description: 'Onion Accounts API', 160 | deploy: true, 161 | deployOptions: { 162 | stageName: 'prod', 163 | loggingLevel: apigw.MethodLoggingLevel.INFO, 164 | }, 165 | }); 166 | 167 | const accounts: apigw.Resource = accountsApi.root.addResource('accounts'); 168 | const account: apigw.Resource = accounts.addResource('{id}'); 169 | const playlists: apigw.Resource = account.addResource('playlists'); 170 | const playlist: apigw.Resource = playlists.addResource('{playlistId}'); 171 | 172 | playlist.addMethod( 173 | 'POST', 174 | new apigw.LambdaIntegration(addSongToPlaylistLambda, { 175 | proxy: true, 176 | }) 177 | ); 178 | 179 | accounts.addMethod( 180 | 'POST', 181 | new apigw.LambdaIntegration(createAccountLambda, { 182 | proxy: true, 183 | }) 184 | ); 185 | 186 | playlists.addMethod( 187 | 'POST', 188 | new apigw.LambdaIntegration(createPlaylistLambda, { 189 | proxy: true, 190 | }) 191 | ); 192 | 193 | account.addMethod( 194 | 'GET', 195 | new apigw.LambdaIntegration(retrieveAccountLambda, { 196 | proxy: true, 197 | }) 198 | ); 199 | 200 | account.addMethod( 201 | 'PATCH', 202 | new apigw.LambdaIntegration(upgradeAccountLambda, { 203 | proxy: true, 204 | }) 205 | ); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /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 | "@repositories/*": ["./stateless/src/repositories/*"], 30 | "@schemas/*": ["./stateless/src/schemas/*"], 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": "1388a4e9-9ec6-4b14-84af-fb876e8a8692", 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 Next Street\",\n \"postCode\": \"ne91bb\"\n }\n}", 17 | "options": { 18 | "raw": { 19 | "language": "json" 20 | } 21 | } 22 | }, 23 | "url": { 24 | "raw": "https://your-rest-id.execute-api.your-region.amazonaws.com/prod/accounts/", 25 | "protocol": "https", 26 | "host": [ 27 | "your-rest-id", 28 | "execute-api", 29 | "your-region", 30 | "amazonaws", 31 | "com" 32 | ], 33 | "path": ["prod", "accounts", ""] 34 | } 35 | }, 36 | "response": [] 37 | }, 38 | { 39 | "name": "Create Playlist", 40 | "request": { 41 | "method": "POST", 42 | "header": [], 43 | "body": { 44 | "mode": "raw", 45 | "raw": "{\n \"playlistName\": \"coolmusic\"\n}", 46 | "options": { 47 | "raw": { 48 | "language": "json" 49 | } 50 | } 51 | }, 52 | "url": { 53 | "raw": "https://your-rest-id.execute-api.your-region.amazonaws.com/prod/accounts/7d4bb2be-c6c8-4bfb-9478-1b0f4e09f5e4/playlists", 54 | "protocol": "https", 55 | "host": [ 56 | "your-rest-id", 57 | "execute-api", 58 | "your-region", 59 | "amazonaws", 60 | "com" 61 | ], 62 | "path": [ 63 | "prod", 64 | "accounts", 65 | "7d4bb2be-c6c8-4bfb-9478-1b0f4e09f5e4", 66 | "playlists" 67 | ] 68 | } 69 | }, 70 | "response": [] 71 | }, 72 | { 73 | "name": "Add Song To Playlist", 74 | "request": { 75 | "method": "POST", 76 | "header": [], 77 | "body": { 78 | "mode": "raw", 79 | "raw": "{\n \"songId\": \"five\"\n}", 80 | "options": { 81 | "raw": { 82 | "language": "json" 83 | } 84 | } 85 | }, 86 | "url": { 87 | "raw": "https://your-rest-id.execute-api.your-region.amazonaws.com/prod/accounts/7d4bb2be-c6c8-4bfb-9478-1b0f4e09f5e4/playlists/51c18f1e-d3db-41d5-8649-f0f9c06a198d/", 88 | "protocol": "https", 89 | "host": [ 90 | "your-rest-id", 91 | "execute-api", 92 | "your-region", 93 | "amazonaws", 94 | "com" 95 | ], 96 | "path": [ 97 | "prod", 98 | "accounts", 99 | "7d4bb2be-c6c8-4bfb-9478-1b0f4e09f5e4", 100 | "playlists", 101 | "51c18f1e-d3db-41d5-8649-f0f9c06a198d", 102 | "" 103 | ] 104 | } 105 | }, 106 | "response": [] 107 | }, 108 | { 109 | "name": "Get Account by ID", 110 | "request": { 111 | "method": "GET", 112 | "header": [], 113 | "url": { 114 | "raw": "https://your-rest-id.execute-api.your-region.amazonaws.com/prod/accounts/0b162e0a-6a37-4b1a-a2a0-38708404c126", 115 | "protocol": "https", 116 | "host": [ 117 | "your-rest-id", 118 | "execute-api", 119 | "your-region", 120 | "amazonaws", 121 | "com" 122 | ], 123 | "path": ["prod", "accounts", "0b162e0a-6a37-4b1a-a2a0-38708404c126"] 124 | } 125 | }, 126 | "response": [] 127 | }, 128 | { 129 | "name": "Upgrade Account", 130 | "request": { 131 | "method": "PATCH", 132 | "header": [], 133 | "url": { 134 | "raw": "https://your-rest-id.execute-api.your-region.amazonaws.com/prod/accounts/0af07a11-6477-47bd-99b6-a73f694beada", 135 | "protocol": "https", 136 | "host": [ 137 | "your-rest-id", 138 | "execute-api", 139 | "your-region", 140 | "amazonaws", 141 | "com" 142 | ], 143 | "path": ["prod", "accounts", "0af07a11-6477-47bd-99b6-a73f694beada"] 144 | } 145 | }, 146 | "response": [] 147 | } 148 | ] 149 | } 150 | --------------------------------------------------------------------------------