├── .eslintignore ├── .eslintrc.yml ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── common │ ├── adapters │ │ └── DynamodbAdapter.int.mjs │ ├── entities │ │ └── MyEntity.test.mjs │ └── services │ │ └── MyEntityService.int.mjs ├── createItem │ ├── businessLogic.test.mjs │ ├── function.int.mjs │ └── iam-createItem-MyEntityService.int.mjs └── processItem │ ├── functionIsTriggeredByDdbStream.e2e.mjs │ └── iam-processItem-MyEntityService.int.mjs ├── build.sh ├── config ├── deployment.yml ├── e2e.jest.config.mjs ├── integration.jest.config.mjs ├── stage.cjs └── unit.jest.config.js ├── documentation ├── diagrams.drawio ├── high-level.png ├── testing-e2e.png ├── testing-int.png └── testing.png ├── iac ├── dynamodb.yml └── functions.yml ├── jsconfig.json ├── package-lock.json ├── package.json ├── serverless.yml └── src ├── common ├── adapters │ └── DynamoDbAdapter.mjs ├── entities │ └── MyEntity.mjs └── services │ └── MyEntityService.mjs ├── createItem ├── businessLogic.mjs ├── function.mjs ├── schema.eventSchema.json └── schema.responseSchema.json └── processItem └── function.mjs /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .serverless 4 | .webpack 5 | **/*.json 6 | **/*.yml 7 | **/*.env 8 | **/*.md 9 | **/*.sh 10 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | commonjs: true 3 | es6: true 4 | node: true 5 | jest: true 6 | extends: 7 | - airbnb-base 8 | globals: 9 | Atomics: readonly 10 | SharedArrayBuffer: readonly 11 | parserOptions: 12 | ecmaVersion: 2024 13 | rules: 14 | semi: off 15 | line-break-style: off 16 | linebreak-style: off 17 | no-use-before-define: off 18 | import/prefer-default-export: off 19 | comma-dangle: off 20 | no-console: off 21 | import/no-extraneous-dependencies: off 22 | import/extensions: off 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # local config files 9 | .envrc 10 | .env 11 | .nvmrc 12 | .awsenv 13 | 14 | # IDE 15 | .vscode 16 | 17 | # trash 18 | .DS_store 19 | .DS_Store 20 | template.drawio.bak 21 | 22 | # schemas 23 | schema.*.mjs 24 | 25 | # build 26 | dist 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paweł Zubkiewicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-hexagonal-template 2 | 3 | [![GitHub license](https://img.shields.io/github/license/serverlesspolska/serverless-hexagonal-template)](https://github.com/serverlesspolska/serverless-hexagonal-template/blob/main/LICENSE) 4 | [![GitHub stars](https://img.shields.io/github/stars/serverlesspolska/serverless-hexagonal-template)](https://github.com/serverlesspolska/serverless-hexagonal-template/stargazers) 5 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 6 | 7 | 8 | Highly opinionated project template for [Serverless Framework](https://www.serverless.com/) that applies **hexagonal architecture** principles to the serverless world. Crafted with easy testing in mind. 9 | 10 | # Recent modernization 11 | 12 | At the beginning of 2024 this project has been refurbished. 13 | 14 | Here's a snapshot of significant updates that have been done: 15 | * 𝘿𝙚𝙥𝙚𝙣𝙙𝙚𝙣𝙘𝙮 𝙖𝙣𝙙 𝙍𝙪𝙣𝙩𝙞𝙢𝙚 𝙐𝙥𝙜𝙧𝙖𝙙𝙚: We've successfully transitioned from Node 16 to Node 20, ensuring our project stays at the cutting edge of technology. 16 | * 𝙀𝙢𝙗𝙧𝙖𝙘𝙞𝙣𝙜 𝙈𝙤𝙙𝙚𝙧𝙣 𝙅𝙖𝙫𝙖𝙎𝙘𝙧𝙞𝙥𝙩: By shifting from require statements to import, our code now fully leverages Node modules, streamlining our development process. 17 | * 𝘼𝙒𝙎 𝙎𝘿𝙆 𝙀𝙫𝙤𝙡𝙪𝙩𝙞𝙤𝙣: Our migration from AWS SDK v2 to v3 marks a significant leap forward in efficiency and performance. 18 | * 𝙈𝙞𝙙𝙙𝙡𝙚𝙬𝙖𝙧𝙚 𝙖𝙣𝙙 𝙏𝙚𝙨𝙩𝙞𝙣𝙜 𝙀𝙣𝙝𝙖𝙣𝙘𝙚𝙢𝙚𝙣𝙩𝙨: Updates to Middy v5 middleware and aws-testing-library have fortified our project, eliminating deprecated dependencies and vulnerabilities. 19 | * 𝙊𝙥𝙩𝙞𝙢𝙞𝙯𝙞𝙣𝙜 𝘼𝙋𝙄 𝘾𝙖𝙡𝙡𝙨: Replacing Axios with native fetch has optimized our API interactions and reduced our project's complexity. 20 | * 𝙎𝙩𝙧𝙪𝙘𝙩𝙪𝙧𝙚𝙙 𝙇𝙤𝙜𝙜𝙞𝙣𝙜 𝙬𝙞𝙩𝙝 𝙋𝙤𝙬𝙚𝙧𝙏𝙤𝙤𝙡𝙨: The introduction of the PowerTools logger has transformed our logging process, enabling more effective tracking and analysis. 21 | * 𝙀𝙣𝙝𝙖𝙣𝙘𝙚𝙙 𝙋𝙚𝙧𝙛𝙤𝙧𝙢𝙖𝙣𝙘𝙚 with AJV Pre-compilation: By introducing AJV pre-compilation of schemas for Middy Validator, we've dramatically 𝗿𝗲𝗱𝘂𝗰𝗲𝗱 𝗼𝘂𝗿 𝗹𝗮𝗺𝗯𝗱𝗮 𝗽𝗮𝗰𝗸𝗮𝗴𝗲 𝘀𝗶𝘇𝗲 𝗳𝗿𝗼𝗺 𝟭.𝟳𝗠𝗕 𝘁𝗼 𝟰𝟳𝟴𝗞𝗕 (𝟳𝟮%). This significant reduction lowers cold start times and boosts overall performance. 22 | * 𝙎𝙞𝙢𝙥𝙡𝙞𝙛𝙞𝙘𝙖𝙩𝙞𝙤𝙣 𝙤𝙛 𝙘𝙧𝙚𝙙𝙚𝙣𝙩𝙞𝙖𝙡 𝙢𝙖𝙣𝙖𝙜𝙚𝙢𝙚𝙣𝙩: AWS CLI profile was removed from the configuration file due to complications it introduced in CI/CD configurations. 23 | 24 | # Quick start 25 | 26 | This is a *template* from which you can create your own project by executing following command: 27 | ``` 28 | sls create --template-url https://github.com/serverlesspolska/serverless-hexagonal-template/tree/main --name your-project-name 29 | ``` 30 | 31 | Next install dependencies: 32 | ``` 33 | cd your-project-name 34 | npm i 35 | ``` 36 | and deploy to your `dev` stage in default region: 37 | ``` 38 | sls deploy 39 | ``` 40 | # High-level architecture 41 | This template implements depicted below architecture. The application itself is just an example used to show you how to test serverless architectures. 42 | 43 | You can easily modify the source code and tailor it to your needs. 44 | ![High-level architecture](documentation/high-level.png) 45 | # Why use this template? 46 | This template project was created with two goals in mind: ***streamlined developer's flow*** and ***easy testing***, because, sadly, both are not common in serverless development yet. 47 | 48 | ## Standardized structure 49 | The project structure has been worked out as a result of years of development in Lambda environment using Serverless Framework. It also takes from the collective experience of the community (to whom I am grateful) embodied in books, talks, videos and articles. 50 | 51 | This template aims to deliver ***common structure*** that would speed up development by providing sensible defaults for boilerplate configurations. It defines *where what* should be placed (i.e. source code in `src/` folder, tests in `__tests__` etc.) so you don't need to waste time on thinking about it every time you start new project and creates common language for your team members. Thus, decreasing *cognitive overload* when switching between projects started from this template. 52 | 53 | ### Structure explanation 54 | There are some guidelines in terms of folders structure and naming conventions. 55 | 56 | |Path|Description|Reason| 57 | |-|-|-| 58 | |`./__tests__`|default folder name for tests when using `jest`. Substructure of this folder follows ***exactly*** the `./src/` structure.|Don't keep tests together with implementation. Easier to exclude during deployment. Easier to distinguish between code and implementation.| 59 | |`./config`|all additional config files for `jest` and deployment|`deployment.yml` is included in `serverless.yml`, it is separate because when you have multiple microservices making single system they should share same *stages* and *regions*. Also you can put VPC configuration there.| 60 | |`./documentation`|You may keep here any documentation about the project.|| 61 | |`./src`|Implementation code goes here|This is a widespread convention.| 62 | |`./src//`|Each Lambda function has it's own folder|Better organization of the code.| 63 | |`./src//function.js`|Every file that implements Lambda's `handler` method is named `function.js`. The handler method name is always `handler`|Easy to find Lambda handlers.| 64 | |`./src/common/`|Place where common elements of implementation are stored. Most implementation code goes here. You should follow [Single-responsibility principle](https://en.wikipedia.org/wiki/Single-responsibility_principle).|**Makes testing possible and really easy**!| 65 | |`./src/common/adapters/Adapter.js`|For files implementing *technical* part of an Adapter in terms of Hexagonal Architecture|This should be very generic i.e. class that allows connections to MySQL **without** any particular SQL code that you want to execute in your usecase.| 66 | |`./src/common/entities/.js`|Here you keep files that represent objects stored in your *repository* (database)|Useful when you use database 😉| 67 | |`./src/common/services/Service.js`|For files implementing *business* part of an Adapter in terms of Hexagonal Architecture|This service uses corresponding `Adapter.js` adapter or adapters. You would put a specific SQL code here and pass it to adapter instance to execute it.| 68 | 69 | After working a lot with that structure I ***saw a pattern emerging*** that `Adapter.js` together with `Service.js` they both implement an adapter in terms of Hexagonal Architecture. Moreover, usually well written adapters (`Adapter.js`) can be reused among different projects (can be put into separate NPM package). 70 | 71 | ### File naming conventions 72 | If a file contains a `class` it should be named starting with big letter i.e `StorageService.js`. If file does not contain a class but one or many JavaScript functions name start from small letter i.e `s3Adapter.js`. 73 | 74 | ## Hexagonal architecture 75 | Design of the code has been planned with hexagonal architecture in mind. It allows better separation of concerns and makes it easier to write code that follows single responsibility principle. Those are crucial characteristics that create architectures which are easy to maintain, extend and test. 76 | 77 | ## Easy testing 78 | Tests are written using `jest` framework and are divided into three separate groups: 79 | * unit 80 | * integration 81 | * end to end (e2e) 82 | 83 | ![Testing diagram](documentation/testing.png) 84 | Note: Unit tests not shown on the diagram for clarity. 85 | ### Unit tests 86 | Those tests are executed locally (on developers computer or CI/CD server) and don't require access to any resources in the AWS cloud or on the Internet. 87 | 88 | Unit tests are ideal to test your *business logic*. You may also decide to test your *services* (located at `src/common/services`). Both are really easy to do when using **hexagonal architecture**. 89 | 90 | Please **don't** mock whole AWS cloud in order to test everything locally. This is wrong path. Just don't do that. 😉 91 | 92 | ``` 93 | npm run test 94 | ``` 95 | 96 | ### Integration tests 97 | 98 | Integration tests focus on pieces of your code (in OOP I'd say *classes*), that realize particular *low* & *mid* level actions. For example your Lambda function may read from DynamoDB, so you would write a *service* and *adapter* modules (classes) that would implement this functionality. Integration test would be executed against **real** DynamoDB table provisioned during deployment of that project. However the code will be running locally (on you computer or CI/CD server). 99 | 100 | ![Integration tests](documentation/testing-int.png) 101 | 102 | Those tests require resources in the cloud. In order to execute them you first need to *deploy* this project to AWS. 103 | ``` 104 | sls deploy 105 | npm run integration 106 | ``` 107 | Those commands deploy project to the cloud on `dev` stage and execute integration tests. 108 | 109 | The `npm run integration` command executes underneath the `serverless-export-env` plugin that exports environment variables set for each Lambda function in the cloud. Results of that command are saved locally to the `.awsenv` file. This file is later injected into `jest` context during tests. 110 | 111 | There is also a *shortcut* command that executes tests but doesn't execute `serverless-export-env` plugin. It requires the `.awsenv` file to be already present. Since environment variables don't change that often this can save you time. 112 | ``` 113 | npm run int 114 | ``` 115 | 116 | ### End to end tests (e2e) 117 | End to end tests focus on whole use cases (from beginning to the end) or larger fragments of those. Usually those tests take longer than integration tests. 118 | 119 | An example of such test would be `POST` request sent to API Gateway `/item` endpoint and a check if `processItem` Lambda function was triggered by DynamoDB Streams as a result of saving new item by `createItem` Lambda function invoked by the request. Such approach tests *chain of events* that happen in the cloud and **gives confidence** that integration between multiple services is well configured. 120 | 121 | ![e2e test](documentation/testing-e2e.png) 122 | 123 | This test is implemented in `__tests__/processItem/functionIsTriggeredByDdbStream.e2e.js` 124 | 125 | You can test it yourself by executing: 126 | ``` 127 | npm run e2e 128 | ``` 129 | 130 | #### Run all tests 131 | Convenience command has been added for running all test. Please bare in mind it requires deployed service. 132 | ``` 133 | npm run all 134 | ``` 135 | **Note**: 136 | > Tests will be executed against your individual development environment - [see section below](#deployment). If you want to execute all test on `dev` stage, please execute `npm run all-dev` command. 137 | 138 | #### DEBUG mode 139 | If you want to see logs when running tests on your local machines just set environment variable `DEBUG` to value `ON`. For example: 140 | ``` 141 | DEBUG=ON npm run test 142 | # or 143 | DEBUG=ON npm run integration 144 | ``` 145 | 146 | #### GUI / acceptance tests 147 | End to end tests are not a substitution to GUI or acceptance tests. For those other solutions (such as AWS CloudWatch Synthetics) are needed. 148 | 149 | ## Deployment 150 | ### Isolated per developer stages (multiple development environments) 151 | Many users asked me to add a **feature allowing developers to work in parallel on isolated stages**. Common *best practice* says that each developer should use a separate AWS account for development to avoid conflicts. Unfortunately, for many teams and companies, managing multiple AWS accounts poses a challenge of its own. 152 | 153 | In order to remove that obstacle, I decided to implement a simple solution that would allow many developers to use a single AWS account for development. **Remember, your production stage should be deployed on a different AWS account for security and performance reasons!** 154 | 155 | When executing the `serverless` or `sls` command without the` -s` (stage) parameter, your `username` will be used to name the stage. 156 | 157 | > For example, my user name on my laptop is `pawel` therefore, the stage will be named `serverless-hexagonal-template-dev-pawel`. Settings such as deployment `region` will be inherited from the dev configuration. 158 | 159 | In that way, your colleagues can deploy their own stages (development environments) in the same region on the same AWS account without any conflicts (given that their usernames are unique in the scope of your project). 160 | 161 | To use that feature, simply execute command without providing any stage name: 162 | ``` 163 | sls deploy 164 | ``` 165 | 166 | ### Regular deployment 167 | Deployment to `dev` stage. 168 | ``` 169 | sls deploy -s dev 170 | ``` 171 | Deployment to a specific stage 172 | ``` 173 | sls deploy -s # stage = dev | test | prod 174 | ``` 175 | 176 | The stages configuration is defined in `config/deployment.yml` file. 177 | 178 | ### Deployment credentials 179 | 180 | #### Changes to AWS CLI `profile` configuration 181 | In the previous version of this template, the AWS CLI `profile` was specified in the Serverless Framework configuration file (`config/deployment.yml`) and utilized during the deployment process. This approach has been phased out due to complications it introduced in CI/CD configurations. 182 | 183 | The template now adheres to the standard AWS and Serverless Framework credentials resolution method as outlined in the [AWS documentation](https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html). 184 | 185 | #### Credentials Resolution Order 186 | The system will attempt to resolve your credentials in the following order: 187 | 1. Environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are checked first. 188 | 1. If not found, the `default` AWS `profile` is used. 189 | 1. Other / custom method. 190 | 191 | #### Custom profile configuration 192 | For those requiring a different `profile` than the `default`, it is recommended to use the `direnv` tool. This allows you to specify an AWS profile for your project within the `.envrc` file located at the project root directory, overriding system settings. Ensure that the AWS profile is already defined in your `~/.aws/credentials` file or `~/.aws/config` if you use SSO. 193 | 194 | To set up `direnv`, follow these steps: 195 | 196 | 1. Define your AWS profile in the `.envrc` file to automatically use it within the project's directory and its subdirectories. 197 | ```Bash 198 | # Set a default profile for this directory 199 | export AWS_PROFILE=my-dev-profile 200 | ``` 201 | 2. Alternatively, you can directly set your access keys: 202 | ```Bash 203 | # Set AWS access keys directly 204 | export AWS_ACCESS_KEY_ID= 205 | export AWS_SECRET_ACCESS_KEY= 206 | ``` 207 | **Note**: These credentials are utilized not only during the deployment process but also for integration and end-to-end testing. 208 | 209 | For more information on direnv and its setup, visit https://direnv.net. 210 | 211 | # What's included? 212 | 213 | Serverless Framework plugins: 214 | - [serverless-iam-roles-per-function](https://github.com/functionalone/serverless-iam-roles-per-function) - to manage individual IAM roles for each function 215 | - [serverless-export-env](https://github.com/arabold/serverless-export-env) - to export Lambda functions environment variables in `.awsenv` file 216 | 217 | 218 | Node.js development libraries: 219 | 220 | * AWS SDK 221 | * Eslint with modified airbnb-base see `.eslintrc.yml` 222 | * Jest 223 | * dotenv 224 | * [aws-testing-library](https://github.com/erezrokah/aws-testing-library) for *end to end* testing 225 | * [serverless-logger](https://github.com/serverlesspolska/serverless-logger) 226 | -------------------------------------------------------------------------------- /__tests__/common/adapters/DynamodbAdapter.int.mjs: -------------------------------------------------------------------------------- 1 | import { DynamoDbAdapter } from '../../../src/common/adapters/DynamoDbAdapter.mjs' 2 | 3 | describe('DynamoDB Adapter', () => { 4 | it('should query by field', async () => { 5 | // GIVEN 6 | const db = new DynamoDbAdapter() 7 | 8 | // WHEN 9 | const results = await db.queryByField(process.env.tableName, 'PK', 'fake-fake-fake') 10 | 11 | // THEN 12 | expect(results).toBeTruthy() 13 | expect(results.Count).toBe(0) 14 | }) 15 | 16 | it('should create & delete item', async () => { 17 | // GIVEN 18 | const db = new DynamoDbAdapter() 19 | const paramsCreate = { 20 | Item: { 21 | PK: { S: 'SampleId' }, 22 | Type: { S: 'SampleId' }, 23 | }, 24 | ReturnConsumedCapacity: 'TOTAL', 25 | TableName: process.env.tableName 26 | } 27 | const paramsDelete = { 28 | Key: { 29 | PK: { S: 'SampleId' }, 30 | }, 31 | ReturnConsumedCapacity: 'TOTAL', 32 | TableName: process.env.tableName 33 | } 34 | 35 | // WHEN 36 | const createResults = await db.create(paramsCreate) 37 | const deleteResults = await db.delete(paramsDelete) 38 | const check = await db.queryByField(process.env.tableName, 'PK', 'SampleId') 39 | 40 | // THEN 41 | expect(createResults).toBeTruthy() 42 | expect(createResults.ConsumedCapacity.TableName).toMatch(process.env.tableName) 43 | expect(createResults.ConsumedCapacity.CapacityUnits).toBe(1) 44 | expect(deleteResults.ConsumedCapacity.TableName).toMatch(process.env.tableName) 45 | expect(deleteResults.ConsumedCapacity.CapacityUnits).toBe(1) 46 | expect(check.Count).toBe(0) 47 | }) 48 | 49 | it('should get item', async () => { 50 | // GIVEN 51 | const db = new DynamoDbAdapter() 52 | const paramsCreate = { 53 | Item: { 54 | PK: { S: 'SampleId' }, 55 | Type: { S: 'TestEntity' }, 56 | }, 57 | ReturnConsumedCapacity: 'TOTAL', 58 | TableName: process.env.tableName 59 | } 60 | const paramsDelete = { 61 | Key: { 62 | PK: { S: 'SampleId' } 63 | }, 64 | ReturnConsumedCapacity: 'TOTAL', 65 | TableName: process.env.tableName 66 | } 67 | const paramsGet = { 68 | Key: { 69 | PK: { S: 'SampleId' } 70 | }, 71 | ReturnConsumedCapacity: 'TOTAL', 72 | TableName: process.env.tableName 73 | } 74 | 75 | // WHEN 76 | const createResults = await db.create(paramsCreate) 77 | const getResults = await db.get(paramsGet) 78 | const deleteResults = await db.delete(paramsDelete) 79 | const check = await db.queryByField(process.env.tableName, 'PK', 'SampleId') 80 | 81 | // THEN 82 | expect(createResults).toBeTruthy() 83 | expect(createResults.ConsumedCapacity.TableName).toMatch(process.env.tableName) 84 | expect(createResults.ConsumedCapacity.CapacityUnits).toBe(1) 85 | expect(getResults.Item.PK.S).toBe('SampleId') 86 | expect(getResults.Item.Type.S).toBe('TestEntity') 87 | expect(deleteResults.ConsumedCapacity.TableName).toMatch(process.env.tableName) 88 | expect(deleteResults.ConsumedCapacity.CapacityUnits).toBe(1) 89 | expect(check.Count).toBe(0) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /__tests__/common/entities/MyEntity.test.mjs: -------------------------------------------------------------------------------- 1 | import { MyEntity } from '../../../src/common/entities/MyEntity.mjs' 2 | 3 | describe('My Entity', () => { 4 | it('should be created from parameters', () => { 5 | // GIVEN 6 | const params = { 7 | result: 48 8 | } 9 | 10 | // WHEN 11 | const actual = new MyEntity(params) 12 | 13 | // THEN 14 | expect(actual.id).toBeTruthy() 15 | expect(actual.createdAt).toBeTruthy() 16 | expect(actual.result).toBe(48) 17 | }) 18 | 19 | it('should be transformed to DynamoDB structure', () => { 20 | // GIVEN 21 | const params = { 22 | result: 48 23 | } 24 | 25 | // WHEN 26 | const item = new MyEntity(params) 27 | const actual = item.toItem() 28 | 29 | // THEN 30 | expect(actual.PK.S).toBeTruthy() 31 | expect(actual.createdAt.S).toBeTruthy() 32 | expect(actual.result.N).toBe('48') 33 | }) 34 | 35 | it('should be from DynamoDB item to object', () => { 36 | // GIVEN 37 | const params = { 38 | result: 48 39 | } 40 | const item = new MyEntity(params) 41 | const dbItem = item.toItem() 42 | 43 | // WHEN 44 | const actual = MyEntity.fromItem(dbItem) 45 | 46 | // THEN 47 | expect(actual.id).toBe(dbItem.PK.S) 48 | expect(actual.createdAt.toISOString()).toBe(dbItem.createdAt.S) 49 | expect(actual.result).toBe(params.result) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /__tests__/common/services/MyEntityService.int.mjs: -------------------------------------------------------------------------------- 1 | import { MyEntityService } from '../../../src/common/services/MyEntityService.mjs' 2 | 3 | describe('MyEntity service', () => { 4 | it('should create and save new entity', async () => { 5 | // GIVEN 6 | const result = 48 7 | const service = new MyEntityService() 8 | 9 | // WHEN 10 | const actual = await service.create(result) 11 | const itemStoredInDb = await service.getById(actual.id) 12 | 13 | // THEN 14 | expect(itemStoredInDb).toBeTruthy() 15 | expect(actual).toEqual(itemStoredInDb) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /__tests__/createItem/businessLogic.test.mjs: -------------------------------------------------------------------------------- 1 | import { performCalculation } from '../../src/createItem/businessLogic.mjs' 2 | 3 | describe('Creat Item buiness logic suite', () => { 4 | it('should add numbers', () => { 5 | // GIVEN 6 | const a = 2 7 | const b = 5 8 | const method = 'add' 9 | 10 | // WHEN 11 | const actual = performCalculation({ a, b, method }) 12 | 13 | // THEN 14 | expect(actual).toBe(7) 15 | }) 16 | 17 | it('should throw error on bad method', () => { 18 | // GIVEN 19 | const a = 2 20 | const b = 5 21 | const method = 'divide' 22 | 23 | // WHEN 24 | let error 25 | try { 26 | performCalculation({ a, b, method }) 27 | } catch (e) { 28 | error = e 29 | } 30 | 31 | // THEN 32 | expect(error.message).toBe('Not implemented yet!') 33 | expect(error.status).toBe(400) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /__tests__/createItem/function.int.mjs: -------------------------------------------------------------------------------- 1 | const baseURL = `https://${process.env.httpApiGatewayEndpointId}.execute-api.${process.env.region}.amazonaws.com` 2 | 3 | describe('createItem function', () => { 4 | it('should respond with statusCode 200 to correct request', async () => { 5 | // GIVEN 6 | const payload = { 7 | a: 10, 8 | b: 5, 9 | method: 'add' 10 | } 11 | 12 | // WHEN 13 | const response = await fetch(`${baseURL}/item`, { 14 | method: 'POST', 15 | body: JSON.stringify(payload), 16 | headers: { 17 | "Content-Type": "application/json", 18 | } 19 | }) 20 | 21 | // THEN 22 | expect(response.status).toBe(200) 23 | }) 24 | 25 | it('should respond with Bad Request 400 to incorrect request', async () => { 26 | // GIVEN 27 | const wrongPayload = {} 28 | 29 | // WHEN 30 | const response = await fetch(`${baseURL}/item`, { 31 | method: 'POST', 32 | body: JSON.stringify(wrongPayload), 33 | headers: { 34 | "Content-Type": "application/json", 35 | } 36 | }) 37 | 38 | // THEN 39 | expect(response.status).toBe(400) 40 | }) 41 | 42 | it('should respond with Not implemented yet for other methods than add', async () => { 43 | // GIVEN 44 | const payload = { 45 | a: 10, 46 | b: 5, 47 | method: 'divide' 48 | } 49 | 50 | // WHEN 51 | const response = await fetch(`${baseURL}/item`, { 52 | method: 'POST', 53 | body: JSON.stringify(payload), 54 | headers: { 55 | "Content-Type": "application/json", 56 | } 57 | }) 58 | const text = await response.text() 59 | 60 | // THEN 61 | expect(response.status).toBe(400) 62 | expect(text).toEqual('Not implemented yet!') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /__tests__/createItem/iam-createItem-MyEntityService.int.mjs: -------------------------------------------------------------------------------- 1 | import IamTestHelper from 'serverless-iam-test-helper'; 2 | 3 | import { MyEntityService } from '../../src/common/services/MyEntityService.mjs' 4 | import { DynamoDbAdapter } from '../../src/common/adapters/DynamoDbAdapter.mjs'; 5 | 6 | const cleanup = [] 7 | 8 | describe('CreateItem Lambda IAM Role', () => { 9 | beforeAll(async () => { 10 | await IamTestHelper.assumeRoleByLambdaName('createItem') 11 | }); 12 | 13 | // this a compensation method, that deletes from database all items created by the test 14 | // since createItem lambda IAM role does not have privileges to remove item from DynamoDB 15 | // IamTestHelper.leaveLambdaRole() is executed to switch back to user's (your) IAM privileges 16 | afterAll(async () => { 17 | IamTestHelper.leaveLambdaRole() 18 | 19 | const userRoleAdapter = new DynamoDbAdapter() 20 | const deleteAll = cleanup.map((obj) => userRoleAdapter.delete({ 21 | Key: obj.key(), 22 | TableName: process.env.tableName 23 | })) 24 | await Promise.all(deleteAll) 25 | }); 26 | 27 | it('should ALLOW dynamodb:PutItem', async () => { 28 | // GIVEN 29 | const result = 48 30 | const service = new MyEntityService() 31 | 32 | // WHEN 33 | const actual = await service.create(result) 34 | 35 | // THEN 36 | expect(actual).toBeTruthy() 37 | expect(actual.result).toBe(result) 38 | expect(actual.id.length).toBeGreaterThan(10) 39 | 40 | // expect actual.createdAt to be less than 1 minute old 41 | const now = new Date() 42 | const createdAt = new Date(actual.createdAt) 43 | expect(now.getTime() - createdAt.getTime()).toBeLessThan(60 * 1000) 44 | 45 | // CLEANUP 46 | cleanup.push(actual) // afterAll method above 47 | }) 48 | 49 | it('should DENY dynamodb:GetItem', async () => { 50 | // GIVEN 51 | const service = new MyEntityService() 52 | 53 | // WHEN 54 | let exception 55 | try { 56 | await service.getById('any id') 57 | } catch (error) { 58 | exception = error 59 | } 60 | 61 | // THEN 62 | expect(exception.name).toBe('AccessDeniedException') 63 | expect(exception.message.includes('is not authorized to perform: dynamodb:GetItem')).toBeTruthy() 64 | }) 65 | 66 | it('should DENY dynamodb:Query', async () => { 67 | // GIVEN 68 | const result = 48 69 | const service = new MyEntityService() 70 | 71 | // WHEN 72 | let exception 73 | try { 74 | await service.getByResult(result) 75 | } catch (error) { 76 | exception = error 77 | } 78 | 79 | // THEN 80 | expect(exception.name).toBe('AccessDeniedException') 81 | expect(exception.message.includes('is not authorized to perform: dynamodb:Query')).toBeTruthy() 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /__tests__/processItem/functionIsTriggeredByDdbStream.e2e.mjs: -------------------------------------------------------------------------------- 1 | const baseURL = `https://${process.env.httpApiGatewayEndpointId}.execute-api.${process.env.region}.amazonaws.com` 2 | 3 | describe('processItem Lambda function', () => { 4 | it('should be invoked by DDB Stream after createItem Lambda saves element into DynamoDB', async () => { 5 | // GIVEN 6 | const payload = { 7 | a: 10, 8 | b: 5, 9 | method: 'add' 10 | } 11 | 12 | // WHEN 13 | const response = await fetch(`${baseURL}/item`, { 14 | method: 'POST', 15 | body: JSON.stringify(payload), 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | } 19 | }) 20 | const actual = await response.json() 21 | const newItemDbId = actual.id 22 | 23 | // THEN 24 | expect(response.status).toBe(200) 25 | 26 | // Using aws-testing-library lib that extends jest framework 27 | await expect({ 28 | region: process.env.region, 29 | function: `${process.env.service}-${process.env.stage}-processItem`, 30 | timeout: 25 * 1000 31 | }).toHaveLog( 32 | /* 33 | A log message in the processItem Lambda function containing the ID 34 | of the newly created item confirms that the function was successfully 35 | invoked and that the DynamoDB Stream integration is correctly configured. 36 | */ 37 | `Processing item ${newItemDbId}` 38 | ); 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /__tests__/processItem/iam-processItem-MyEntityService.int.mjs: -------------------------------------------------------------------------------- 1 | import IamTestHelper from 'serverless-iam-test-helper'; 2 | 3 | import { MyEntityService } from '../../src/common/services/MyEntityService.mjs'; 4 | 5 | describe('ProcessItem Lambda IAM Role', () => { 6 | beforeAll(async () => { 7 | await IamTestHelper.assumeRoleByLambdaName('processItem') 8 | }); 9 | 10 | afterAll(() => { 11 | IamTestHelper.leaveLambdaRole() 12 | }) 13 | 14 | it('should ALLOW dynamodb:GetItem', async () => { 15 | // GIVEN 16 | const service = new MyEntityService() 17 | 18 | // WHEN 19 | await service.getById('any id') 20 | 21 | // THEN 22 | // lack of exception means that GetItem action is allowed 23 | // TODO improve that test there is explicit assertion 24 | }) 25 | 26 | it('should DENY dynamodb:PutItem', async () => { 27 | // GIVEN 28 | const result = 48 29 | const service = new MyEntityService() 30 | 31 | // WHEN 32 | let exception 33 | try { 34 | await service.create(result) 35 | } catch (error) { 36 | exception = error 37 | } 38 | 39 | // THEN 40 | expect(exception.name).toBe('AccessDeniedException') 41 | expect(exception.message.includes('is not authorized to perform: dynamodb:PutItem')).toBeTruthy() 42 | }) 43 | it('should DENY dynamodb:Query', async () => { 44 | // GIVEN 45 | const result = 48 46 | const service = new MyEntityService() 47 | 48 | // WHEN 49 | let exception 50 | try { 51 | await service.getByResult(result) 52 | } catch (error) { 53 | exception = error 54 | } 55 | 56 | // THEN 57 | expect(exception.name).toBe('AccessDeniedException') 58 | expect(exception.message.includes('is not authorized to perform: dynamodb:Query')).toBeTruthy() 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This build script finds all files with name in format schema.*.json and pre-transpiles the schemas 4 | # from json into mjs files using ajv-cmd. The output files are in the same directory as the source 5 | # json files. They have the same name but different extension. 6 | 7 | bundle () { 8 | ajv validate ${1} --valid \ 9 | --strict true --coerce-types array --all-errors true --use-defaults empty 10 | ajv transpile ${1} \ 11 | --strict true --coerce-types array --all-errors true --use-defaults empty -o ${1%.json}.mjs 12 | } 13 | 14 | for file in src/*/schema.*.json; do 15 | bundle $file 16 | done 17 | -------------------------------------------------------------------------------- /config/deployment.yml: -------------------------------------------------------------------------------- 1 | deployment: 2 | region: 3 | dev: eu-central-1 # Frankfurt 4 | test: eu-west-1 # Ireland 5 | prod: us-east-1 # N.Virginia 6 | globalStages: # need for per-developer stages 7 | dev: dev 8 | test: test 9 | prod: prod 10 | 11 | # If you need to run Lambda inside VPC uncomment below 12 | # and configure SG and subnets below: 13 | # 14 | # vpc: 15 | # dev: 16 | # securityGroupId: sg-abcdefgh 17 | # subnetId1: subnet-12345678 18 | # subnetId2: subnet-12345678 19 | # test: 20 | # securityGroupId: sg-abcdefgh 21 | # subnetId1: subnet-12345678 22 | # subnetId2: subnet-12345678 23 | # prod: 24 | # securityGroupId: sg-abcdefgh 25 | # subnetId1: subnet-12345678 26 | # subnetId2: subnet-12345678 27 | # 28 | # 29 | # Next in serverless.yml in Lambda function section add following: 30 | # vpc: 31 | # securityGroupIds: 32 | # - ${self:custom.deployment.vpc.${self:provider.stage}.securityGroupId} 33 | # subnetIds: 34 | # - ${self:custom.deployment.vpc.${self:provider.stage}.subnetId1} 35 | # - ${self:custom.deployment.vpc.${self:provider.stage}.subnetId2} -------------------------------------------------------------------------------- /config/e2e.jest.config.mjs: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | export default { 4 | testEnvironment: 'node', 5 | roots: ['../__tests__/'], 6 | testMatch: ['**/*.(e2e).mjs'], 7 | testTimeout: 60000 * 7, // 7 minutes timeout 8 | // Enable this if you're using aws-testing-library 9 | setupFilesAfterEnv: ['../node_modules/aws-testing-library/lib/jest/index.js'], 10 | } 11 | 12 | // Load environment variables generated by serverless-export-env plugin 13 | dotenv.config({ 14 | path: '.awsenv', 15 | bail: 1 16 | }) 17 | -------------------------------------------------------------------------------- /config/integration.jest.config.mjs: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | export default { 4 | testEnvironment: 'node', 5 | roots: ['../__tests__/'], 6 | testMatch: ['**/*.(int|integration).mjs'], 7 | testTimeout: 60000 * 2, // 2 minutes timeout 8 | } 9 | 10 | // Load environment variables generated by serverless-export-env plugin 11 | dotenv.config({ 12 | path: '.awsenv', 13 | bail: 1, 14 | testEnvironment: 'node' 15 | }) 16 | -------------------------------------------------------------------------------- /config/stage.cjs: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | 3 | const stage = () => { 4 | console.log(`Stage not provided. Using "local" stage name based on username: 'dev-${os.userInfo().username}'.`); 5 | return `dev-${os.userInfo().username}` 6 | } 7 | 8 | module.exports.userStage = stage 9 | -------------------------------------------------------------------------------- /config/unit.jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: 'node', 3 | roots: ['../'], 4 | testMatch: ['**/*.test.js', '**/*.test.mjs'] 5 | } 6 | -------------------------------------------------------------------------------- /documentation/diagrams.drawio: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /documentation/high-level.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/1e6b0435ea206c07bff5a52ea7a7180f14bc6570/documentation/high-level.png -------------------------------------------------------------------------------- /documentation/testing-e2e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/1e6b0435ea206c07bff5a52ea7a7180f14bc6570/documentation/testing-e2e.png -------------------------------------------------------------------------------- /documentation/testing-int.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/1e6b0435ea206c07bff5a52ea7a7180f14bc6570/documentation/testing-int.png -------------------------------------------------------------------------------- /documentation/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/1e6b0435ea206c07bff5a52ea7a7180f14bc6570/documentation/testing.png -------------------------------------------------------------------------------- /iac/dynamodb.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | Table: 3 | Type: AWS::DynamoDB::Table 4 | Properties: 5 | AttributeDefinitions: 6 | - AttributeName: PK 7 | AttributeType: S 8 | KeySchema: 9 | - AttributeName: PK 10 | KeyType: HASH 11 | BillingMode: PAY_PER_REQUEST 12 | TableName: ${self:custom.tableName} 13 | Tags: 14 | - Key: Application 15 | Value: ${self:service} 16 | - Key: Stage 17 | Value: ${self:provider.stage} 18 | - Key: StackName 19 | Value: !Ref AWS::StackId 20 | StreamSpecification: 21 | StreamViewType: NEW_IMAGE -------------------------------------------------------------------------------- /iac/functions.yml: -------------------------------------------------------------------------------- 1 | createItem: 2 | handler: src/createItem/function.handler 3 | description: Create Item in repository 4 | memorySize: 128 5 | timeout: 5 6 | environment: 7 | tableName: ${self:custom.tableName} 8 | events: 9 | - httpApi: 10 | method: POST 11 | path: /item 12 | iamRoleStatements: 13 | - Sid: DynamoDBReadWrite 14 | Effect: Allow 15 | Action: 16 | - dynamodb:PutItem 17 | - dynamodb:UpdateItem 18 | Resource: 19 | - !GetAtt Table.Arn 20 | 21 | processItem: 22 | handler: src/processItem/function.handler 23 | description: Triggered by DynamoDB Streams. Does some work on newly created Item 24 | memorySize: 128 25 | timeout: 5 26 | environment: 27 | message: Hello World! 28 | events: 29 | - stream: 30 | type: dynamodb 31 | arn: !GetAtt Table.StreamArn 32 | maximumRetryAttempts: 1 33 | batchSize: 1 34 | iamRoleStatements: 35 | - Sid: DynamoDBRead 36 | Effect: Allow 37 | Action: 38 | - dynamodb:GetItem 39 | Resource: 40 | - !GetAtt Table.Arn -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2022", 4 | "target": "ES2022" 5 | }, 6 | "include": [ 7 | "src/**/*", 8 | "__tests__/**/*" 9 | ], 10 | "exclude": [ 11 | "node_modules" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-hexagonal-template", 3 | "version": "1.0.0", 4 | "description": "Highly opinionated project template for Serverless Framework that follows and applies hexagonal architecture principle to serverless world. Prepared with easy testing in mind.", 5 | "author": "Pawel Zubkiewicz", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "eslint": "node_modules/.bin/eslint src/**/*.mjs --ignore-pattern node_modules/", 10 | "export-env-dev": "STAGE=${STAGE:=dev} && sls export-env --all -s $STAGE", 11 | "export-env-local": "sls export-env --all", 12 | "test": "npm run build && node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/unit.jest.config.js", 13 | "integration": "npm run export-env-local && node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/integration.jest.config.mjs", 14 | "int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/integration.jest.config.mjs", 15 | "e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/e2e.jest.config.mjs", 16 | "all": "npm run test && npm run integration && npm run e2e", 17 | "all-dev": "npm run test && npm run export-env-dev && npm run int && npm run e2e", 18 | "build": "npm i && ./build.sh" 19 | }, 20 | "devDependencies": { 21 | "@aws-sdk/client-dynamodb": "^3.699.0", 22 | "@aws-sdk/lib-dynamodb": "^3.699.0", 23 | "@types/jest": "^29.5.13", 24 | "ajv-cmd": "^0.7.11", 25 | "aws-testing-library": "gerwant/aws-testing-library#modernization", 26 | "dotenv": "^16.4.5", 27 | "eslint": "^8.55.0", 28 | "eslint-config-airbnb-base": "^15.0.0", 29 | "eslint-plugin-import": "^2.26.0", 30 | "jest": "^29.7.0", 31 | "serverless": "^3.39.0", 32 | "serverless-better-credentials": "^2.0.0", 33 | "serverless-export-env": "^2.2.0", 34 | "serverless-iam-roles-per-function": "^3.2.0", 35 | "serverless-iam-test-helper": "^1.0.1", 36 | "serverless-plugin-scripts": "^1.0.2" 37 | }, 38 | "dependencies": { 39 | "@aws-lambda-powertools/logger": "^2.11.0", 40 | "@middy/core": "^5.5.1", 41 | "@middy/http-error-handler": "^5.5.1", 42 | "@middy/http-json-body-parser": "^5.5.1", 43 | "ksuid": "^3.0.0", 44 | "middy-ajv": "^3.0.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-hexagonal-template 2 | 3 | frameworkVersion: '^3' 4 | 5 | plugins: 6 | - serverless-better-credentials 7 | - serverless-iam-roles-per-function 8 | - serverless-export-env 9 | - serverless-plugin-scripts 10 | 11 | provider: 12 | name: aws 13 | stage: ${opt:stage, file(./config/stage.cjs):userStage} 14 | runtime: nodejs20.x 15 | region: ${opt:region, self:custom.deployment.region.${self:custom.globalStage}} 16 | logRetentionInDays: 60 # how long logs are kept in CloudWatch 17 | deploymentMethod: direct 18 | environment: 19 | # required Environment Variables. Don't remove. 20 | stage: ${self:provider.stage} 21 | region: ${self:provider.region} 22 | service: ${self:service} 23 | POWERTOOLS_SERVICE_NAME: ${self:service} 24 | # your variables - optional 25 | httpApiGatewayEndpointId: !Ref HttpApi 26 | tags: 27 | Application: ${self:service} 28 | Stage: ${self:provider.stage} 29 | 30 | configValidationMode: warn 31 | 32 | custom: 33 | deployment: ${file(config/deployment.yml):deployment} 34 | globalStage: ${self:custom.deployment.globalStages.${self:provider.stage}, 'dev'} 35 | description: Your short project description that will be shown in Lambda -> Applications console & in CloudFormation stack 36 | tableName: ${self:service}-${self:provider.stage} 37 | export-env: # serverless-export-env config 38 | filename: .awsenv # custom filename to avoid conflict with Serverless Framework '.env' auto loading feature 39 | overwrite: true 40 | scripts: 41 | hooks: 42 | 'before:package:createDeploymentArtifacts': npm run build 43 | 44 | functions: ${file(iac/functions.yml)} 45 | 46 | package: 47 | patterns: 48 | # exclude 49 | - '!__tests__/**' 50 | - '!documentation/**' 51 | - '!config/**' 52 | - '!iac/**' 53 | - '!src/**/schema.*.json' 54 | - '!*' 55 | 56 | resources: 57 | - Description: ${self:custom.description} 58 | # define each resource in a separate file per service in `iac` folder 59 | - ${file(iac/dynamodb.yml)} 60 | -------------------------------------------------------------------------------- /src/common/adapters/DynamoDbAdapter.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | TransactWriteItemsCommand, GetItemCommand, PutItemCommand, UpdateItemCommand, 3 | DeleteItemCommand, DynamoDBClient 4 | } from '@aws-sdk/client-dynamodb'; 5 | import { QueryCommand, DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 6 | import { Logger } from '@aws-lambda-powertools/logger' 7 | 8 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() }); 9 | 10 | export class DynamoDbAdapter { 11 | constructor() { 12 | this.client = new DynamoDBClient({ 13 | region: process.env.region 14 | }); 15 | this.documentClient = DynamoDBDocumentClient.from(this.client); 16 | } 17 | 18 | async queryByField(TableName, field, value) { 19 | const params = { 20 | TableName, 21 | // IndexName: indexName, 22 | KeyConditionExpression: '#field = :value', 23 | ExpressionAttributeNames: { 24 | '#field': field 25 | }, 26 | ExpressionAttributeValues: { 27 | ':value': value 28 | } 29 | }; 30 | return this.query(params); 31 | } 32 | 33 | async queryIndexByField(IndexName, field, value) { 34 | const params = { 35 | IndexName, 36 | KeyConditionExpression: '#field = :value', 37 | ExpressionAttributeNames: { 38 | '#field': field 39 | }, 40 | ExpressionAttributeValues: { 41 | ':value': value 42 | } 43 | }; 44 | return this.query(params); 45 | } 46 | 47 | async query(params) { 48 | return this.documentClient.send(new QueryCommand(params)) 49 | } 50 | 51 | async get(params) { 52 | return this.client.send(new GetItemCommand(params)); 53 | } 54 | 55 | async createItem(tableName, entity) { 56 | logger.info('Saving new item into DynamoDB Table', { 57 | itemId: entity.id, 58 | tableName, 59 | }) 60 | const params = { 61 | Item: entity.toItem(), 62 | ReturnConsumedCapacity: 'TOTAL', 63 | TableName: tableName 64 | } 65 | try { 66 | await this.create(params) 67 | logger.info('Item saved successfully') 68 | return entity 69 | } catch (error) { 70 | logger.error('Item not saved', error) 71 | throw error 72 | } 73 | } 74 | 75 | async create(params) { 76 | return this.client.send(new PutItemCommand(params)) 77 | } 78 | 79 | async delete(params) { 80 | logger.info('Deleting item', { 81 | PK: params.Key.PK.S, 82 | SK: params.Key.SK ? params.Key.SK.S : 'not present', 83 | tableName: params.TableName, 84 | }) 85 | return this.client.send(new DeleteItemCommand(params)) 86 | } 87 | 88 | async update(params) { 89 | return this.client.send(new UpdateItemCommand(params)) 90 | } 91 | 92 | async transactWrite(params) { 93 | const transactionCommand = new TransactWriteItemsCommand(params); 94 | return this.client.send(transactionCommand) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/common/entities/MyEntity.mjs: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'node:crypto'; 2 | import KSUID from 'ksuid'; 3 | 4 | export class MyEntity { 5 | constructor({ id, result, createdAt = new Date() }) { 6 | this.createdAt = createdAt instanceof Date ? createdAt : new Date(createdAt) 7 | this.result = parseInt(result, 10) 8 | this.id = id || this.generateId(createdAt) 9 | } 10 | 11 | key() { 12 | return { 13 | PK: { S: this.id } 14 | } 15 | } 16 | 17 | static fromItem(item) { 18 | return new MyEntity({ 19 | id: item.PK.S, 20 | result: item.result.N, 21 | createdAt: item.createdAt.S 22 | }) 23 | } 24 | 25 | toItem() { 26 | return { 27 | ...this.key(), 28 | result: { N: this.result.toString() }, 29 | createdAt: { S: this.createdAt.toISOString() }, 30 | } 31 | } 32 | 33 | // eslint-disable-next-line class-methods-use-this 34 | generateId(createdAt) { 35 | const payload = randomBytes(16) 36 | return KSUID.fromParts(createdAt.getTime(), payload).string 37 | } 38 | 39 | toDto() { 40 | return { 41 | id: this.id, 42 | result: this.result, 43 | createdAt: this.createdAt.toISOString() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/common/services/MyEntityService.mjs: -------------------------------------------------------------------------------- 1 | import { Logger } from '@aws-lambda-powertools/logger' 2 | 3 | import { DynamoDbAdapter } from '../adapters/DynamoDbAdapter.mjs'; 4 | import { MyEntity } from '../entities/MyEntity.mjs' 5 | 6 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() }); 7 | 8 | export class MyEntityService { 9 | constructor(dynamoDbAdapter) { 10 | this.dynamoDbAdapter = dynamoDbAdapter || new DynamoDbAdapter() 11 | this.tableName = process.env.tableName 12 | } 13 | 14 | async create(result) { 15 | logger.info('Creating MyEntity item in repository') 16 | const myEntity = new MyEntity({ result }) 17 | await this.dynamoDbAdapter.createItem(this.tableName, myEntity) 18 | return myEntity 19 | } 20 | 21 | async getById(id) { 22 | const paramsGet = { 23 | Key: { 24 | PK: { S: id } 25 | }, 26 | ReturnConsumedCapacity: 'TOTAL', 27 | TableName: this.tableName 28 | } 29 | const response = await this.dynamoDbAdapter.get(paramsGet) 30 | const item = response.Item 31 | 32 | if (item) { 33 | return new MyEntity({ id: item.PK.S, result: item.result.N, createdAt: item.createdAt.S }); 34 | } 35 | return item; 36 | } 37 | 38 | async getByResult(value) { 39 | const response = await this.dynamoDbAdapter.queryByField(this.tableName, 'result', value) 40 | // eslint-disable-next-line max-len 41 | return response.Items.map((item) => new MyEntity({ id: item.PK, result: item.result, createdAt: item.createdAt })) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/createItem/businessLogic.mjs: -------------------------------------------------------------------------------- 1 | import { Logger } from '@aws-lambda-powertools/logger' 2 | 3 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() }); 4 | 5 | export const performCalculation = ({ a, b, method }) => { 6 | logger.info('Received method with values', { 7 | method, 8 | values: { 9 | a, 10 | b, 11 | } 12 | }) 13 | switch (method) { 14 | case 'add': 15 | return a + b 16 | // To Do - implement other methods (just sample, not a real to do) 17 | default: 18 | throw new NotImplementedYetError() 19 | } 20 | } 21 | 22 | class NotImplementedYetError extends Error { 23 | constructor() { 24 | super() 25 | this.status = 400 26 | this.statusCode = 400 27 | this.name = 'NotImplementedYetError' 28 | this.message = 'Not implemented yet!' 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/createItem/function.mjs: -------------------------------------------------------------------------------- 1 | import middy from '@middy/core' 2 | import jsonBodyParser from '@middy/http-json-body-parser' 3 | import httpErrorHandler from '@middy/http-error-handler' 4 | import validator from 'middy-ajv' 5 | import { Logger } from '@aws-lambda-powertools/logger' 6 | 7 | import { performCalculation } from './businessLogic.mjs' 8 | import { MyEntityService } from '../common/services/MyEntityService.mjs' 9 | import eventSchema from './schema.eventSchema.mjs' 10 | import responseSchema from './schema.responseSchema.mjs' 11 | 12 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() }); 13 | 14 | const lambdaHandler = async (event) => { 15 | logger.info('Starting Lambda function') 16 | const result = performCalculation(event.body) 17 | const myEntityService = new MyEntityService() 18 | return (await myEntityService.create(result)).toDto() 19 | } 20 | 21 | export const handler = middy() 22 | .use(jsonBodyParser()) 23 | .use(validator({ eventSchema, responseSchema })) 24 | .use(httpErrorHandler({ logger: (...args) => logger.error(args) })) 25 | .handler(lambdaHandler) 26 | -------------------------------------------------------------------------------- /src/createItem/schema.eventSchema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Event Schema", 3 | "type": "object", 4 | "properties": { 5 | "body": { 6 | "type": "object", 7 | "properties": { 8 | "a": { 9 | "type": "number" 10 | }, 11 | "b": { 12 | "type": "number" 13 | }, 14 | "method": { 15 | "type": "string" 16 | } 17 | }, 18 | "required": [ 19 | "a", 20 | "b", 21 | "method" 22 | ] 23 | } 24 | }, 25 | "required": [ 26 | "body" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/createItem/schema.responseSchema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Response Schema", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "result": { 9 | "type": "number" 10 | }, 11 | "createdAt": { 12 | "type": "string", 13 | "format": "date-time" 14 | } 15 | }, 16 | "required": [ 17 | "id", 18 | "result", 19 | "createdAt" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/processItem/function.mjs: -------------------------------------------------------------------------------- 1 | import { Logger } from '@aws-lambda-powertools/logger' 2 | 3 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() }); 4 | 5 | export const handler = async (event) => { 6 | const item = parseEvent(event) 7 | logger.info('Received item to process', { item }) 8 | 9 | // this log message is used for testing 10 | // don't remove it. See: functionIsTriggeredByDdbStream.e2e.js 11 | logger.info(`Processing item ${item.dynamodb.Keys.PK.S}`) 12 | 13 | // here you can implement rest of your Lambda code 14 | 15 | return true 16 | } 17 | 18 | const parseEvent = (event) => event.Records[0] 19 | --------------------------------------------------------------------------------