diff --git a/packages/website/src/config.tsx b/packages/website/src/config.tsx
index 90bde143d..a199e1363 100644
--- a/packages/website/src/config.tsx
+++ b/packages/website/src/config.tsx
@@ -40,6 +40,9 @@ import {
ExpressIcon,
PostgresIcon,
TypeOrmIcon,
+ ServerlessFrameworkIcon,
+ DynamoDBIcon,
+ SQSIcon,
} from './icons';
export interface NavItem {
@@ -311,20 +314,38 @@ export const TECHNOLOGIES = [
key: 'express',
name: 'Express.js',
tags: ['Framework'],
- Icon: (props) =>
+ Icon: (props) => ,
},
{
key: 'typeorm',
name: 'TypeORM',
tags: ['Data Management'],
- Icon: (props) =>
+ Icon: (props) => ,
},
{
key: 'postgres',
name: 'Postgres',
tags: ['Data Management'],
- Icon: (props) =>
- }
+ Icon: (props) => ,
+ },
+ {
+ key: 'serverless-framework',
+ name: 'Serverless Framework',
+ tags: ['Framework'],
+ Icon: (props) => ,
+ },
+ {
+ key: 'dynamodb',
+ name: 'AWS DynamoDB',
+ tags: ['Data Management'],
+ Icon: (props) => ,
+ },
+ {
+ key: 'sqs',
+ name: 'AWS SQS',
+ tags: ['Data Queue'],
+ Icon: (props) => ,
+ },
];
export const SPONSORS_ICON = [
diff --git a/packages/website/src/icons/DynamoDBIcon.tsx b/packages/website/src/icons/DynamoDBIcon.tsx
new file mode 100644
index 000000000..f530f59fd
--- /dev/null
+++ b/packages/website/src/icons/DynamoDBIcon.tsx
@@ -0,0 +1,32 @@
+import { Props } from './types';
+
+export function DynamoDBIcon({ className }: Props) {
+ return (
+
+ );
+}
diff --git a/packages/website/src/icons/SQSIcon.tsx b/packages/website/src/icons/SQSIcon.tsx
new file mode 100644
index 000000000..c1dfec438
--- /dev/null
+++ b/packages/website/src/icons/SQSIcon.tsx
@@ -0,0 +1,43 @@
+import { Props } from './types';
+
+export function SQSIcon({ className }: Props) {
+ return (
+
+ );
+}
diff --git a/packages/website/src/icons/ServerlessFrameworkIcon.tsx b/packages/website/src/icons/ServerlessFrameworkIcon.tsx
new file mode 100644
index 000000000..f5d0dadab
--- /dev/null
+++ b/packages/website/src/icons/ServerlessFrameworkIcon.tsx
@@ -0,0 +1,18 @@
+import { Props } from './types';
+
+export function ServerlessFrameworkIcon({ className }: Props) {
+ return (
+
+ );
+}
diff --git a/packages/website/src/icons/index.ts b/packages/website/src/icons/index.ts
index 2acfab513..4f04ebf8c 100644
--- a/packages/website/src/icons/index.ts
+++ b/packages/website/src/icons/index.ts
@@ -38,7 +38,10 @@ export { ShareIcon } from './ShareIcon';
export { LinkedinIcon } from './LinkedinIcon';
export { QwikIcon } from './QwikIcon';
export { SolidJsIcon } from './SolidJsIcon';
-export { DenoIcon } from './DenoIcon.tsx';
+export { DenoIcon } from './DenoIcon';
export { ExpressIcon } from './ExpressIcon';
export { PostgresIcon } from './PostgresIcon';
export { TypeOrmIcon } from './TypeOrmIcon';
+export { ServerlessFrameworkIcon } from './ServerlessFrameworkIcon';
+export { DynamoDBIcon } from './DynamoDBIcon';
+export { SQSIcon } from './SQSIcon';
diff --git a/starter-kits.json b/starter-kits.json
index c7738e393..53d233046 100644
--- a/starter-kits.json
+++ b/starter-kits.json
@@ -10,5 +10,6 @@
"solidjs-tailwind": "SolidJs and TailwindCSS",
"angular-ngrx-scss": "Angular, NgRx, and SCSS",
"deno-oak-denodb": "Deno, Oak, and DenoDB",
- "express-typeorm-postgres": "Express.js, TypeOrm, and PostgreSQL"
+ "express-typeorm-postgres": "Express.js, TypeOrm, and PostgreSQL",
+ "serverless-framework-sqs-dynamodb": "Serverless Framework, AWS SQS, and AWS DynamoDB"
}
diff --git a/starters/serverless-framework-sqs-dynamodb/.editorconfig b/starters/serverless-framework-sqs-dynamodb/.editorconfig
new file mode 100644
index 000000000..529be2114
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.editorconfig
@@ -0,0 +1,12 @@
+# Editor configuration, see http://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = tab
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.yml]
+indent_style = space
diff --git a/starters/serverless-framework-sqs-dynamodb/.env.example b/starters/serverless-framework-sqs-dynamodb/.env.example
new file mode 100644
index 000000000..bbef04e5a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.env.example
@@ -0,0 +1,6 @@
+# indicate to serverless if you're offline mode or not
+IS_OFFLINE=true
+# connection info for Redis used for caching
+REDIS_CACHE_URL=redis://:sOmE_sEcUrE_pAsS@localhost:6379
+# how long should items last in the cache by default
+DEFAULT_CACHE_TIME=300000
diff --git a/starters/serverless-framework-sqs-dynamodb/.eslintignore b/starters/serverless-framework-sqs-dynamodb/.eslintignore
new file mode 100644
index 000000000..9d916c36e
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.eslintignore
@@ -0,0 +1,13 @@
+# Ignore artifacts:
+build
+coverage
+node_modules
+.esbuild
+.dynamodb
+.redis
+.serverless
+swagger
+
+# yaml
+*.yaml
+*.yml
diff --git a/starters/serverless-framework-sqs-dynamodb/.eslintrc.js b/starters/serverless-framework-sqs-dynamodb/.eslintrc.js
new file mode 100644
index 000000000..532b14f77
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.eslintrc.js
@@ -0,0 +1,126 @@
+module.exports = {
+ env: {
+ es2021: true,
+ node: true,
+ },
+ extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
+ overrides: [
+ {
+ files: ['**/*.test.ts', 'jest.setupEnvironment.js'],
+ env: {
+ jest: true,
+ },
+ rules: {
+ 'no-unused-expressions': 'off',
+ '@typescript-eslint/no-var-requires': 'off',
+ },
+ },
+ ],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint', 'import', 'promise'],
+ rules: {
+ 'array-callback-return': 'error',
+ 'block-scoped-var': 'error',
+ 'camelcase': ['error', { properties: 'never' }],
+ 'consistent-return': 'error',
+ 'curly': ['error', 'multi-line'],
+ 'dot-notation': 'error',
+ 'eqeqeq': ['error', 'allow-null'],
+ 'guard-for-in': 'error',
+ 'indent': ['error', 'tab'],
+ 'linebreak-style': ['error', 'unix'],
+ 'new-cap': 'error',
+ 'no-array-constructor': 'error',
+ 'no-caller': 'error',
+ 'no-confusing-arrow': ['error', { allowParens: false }],
+ 'no-duplicate-imports': 'error',
+ 'no-else-return': 'error',
+ 'no-eval': 'error',
+ 'no-extra-bind': 'error',
+ 'no-extra-label': 'error',
+ 'no-implied-eval': 'error',
+ 'no-iterator': 'error',
+ 'no-label-var': 'error',
+ 'no-lone-blocks': 'error',
+ 'no-lonely-if': 'error',
+ 'no-loop-func': 'error',
+ 'no-multi-str': 'error',
+ 'no-nested-ternary': 'error',
+ 'no-new': 'error',
+ 'no-new-func': 'error',
+ 'no-new-object': 'error',
+ 'no-new-wrappers': 'error',
+ 'no-octal-escape': 'error',
+ 'no-proto': 'error',
+ 'no-restricted-syntax': ['error', 'ForInStatement', 'WithStatement'],
+ 'no-return-assign': 'error',
+ 'no-script-url': 'error',
+ 'no-self-compare': 'error',
+ 'no-sequences': 'error',
+ 'no-shadow': 'error',
+ 'no-throw-literal': 'error',
+ 'no-undef-init': 'error',
+ 'no-unneeded-ternary': ['error'],
+ 'no-unused-expressions': ['error'],
+ 'no-unused-vars': ['error', { vars: 'local', args: 'after-used' }],
+ 'no-useless-computed-key': 'error',
+ 'no-useless-concat': 'error',
+ 'no-useless-constructor': 'error',
+ 'no-useless-rename': [
+ 'error',
+ { ignoreDestructuring: false, ignoreImport: false, ignoreExport: false },
+ ],
+ 'no-var': 'error',
+ 'no-void': 'error',
+ 'object-shorthand': ['error', 'always', { ignoreConstructors: false, avoidQuotes: true }],
+ 'one-var': ['error', 'never'],
+ 'operator-assignment': ['error', 'always'],
+ 'prefer-arrow-callback': ['error', { allowNamedFunctions: false, allowUnboundThis: true }],
+ 'prefer-const': ['error', { destructuring: 'any', ignoreReadBeforeAssign: true }],
+ 'prefer-rest-params': 'error',
+ 'prefer-spread': 'error',
+ 'prefer-template': 'error',
+ 'quotes': ['error', 'single', { avoidEscape: true }],
+ 'radix': 'error',
+ 'require-atomic-updates': 'off', // Reports false positives: https://github.com/eslint/eslint/issues/11899
+ 'semi': ['error', 'always'],
+ 'spaced-comment': ['error', 'always'],
+ 'strict': ['error', 'safe'],
+ 'vars-on-top': 'error',
+ 'yoda': 'error',
+
+ // import plugin settings
+ 'import/export': 'error',
+ 'import/first': ['error', 'absolute-first'],
+ 'import/newline-after-import': 'error',
+ 'import/no-duplicates': 'error',
+ 'import/no-extraneous-dependencies': [
+ 'error',
+ {
+ devDependencies: [
+ '**/*.test.ts',
+ 'prettier.config.js',
+ 'jest.config.ts',
+ 'jest.setup.ts',
+ 'esbuild-plugins.ts',
+ ],
+ },
+ ],
+ 'import/no-mutable-exports': 'error',
+ 'import/no-named-as-default': 'error',
+ 'import/no-named-as-default-member': 'error',
+ 'import/no-unresolved': ['error', { commonjs: false }],
+
+ // typescript settings
+ '@typescript-eslint/no-empty-function': 'off',
+ },
+ settings: {
+ 'import/resolver': {
+ typescript: {},
+ },
+ },
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/.gitignore b/starters/serverless-framework-sqs-dynamodb/.gitignore
new file mode 100644
index 000000000..f94275c37
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.gitignore
@@ -0,0 +1,10 @@
+coverage
+node_modules
+jspm_packages
+
+.dynamodb
+.esbuild
+.env
+.redis
+.serverless
+swagger
diff --git a/starters/serverless-framework-sqs-dynamodb/.nvmrc b/starters/serverless-framework-sqs-dynamodb/.nvmrc
new file mode 100644
index 000000000..53d838af2
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.nvmrc
@@ -0,0 +1 @@
+lts/gallium
diff --git a/starters/serverless-framework-sqs-dynamodb/.prettierignore b/starters/serverless-framework-sqs-dynamodb/.prettierignore
new file mode 100644
index 000000000..9d916c36e
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.prettierignore
@@ -0,0 +1,13 @@
+# Ignore artifacts:
+build
+coverage
+node_modules
+.esbuild
+.dynamodb
+.redis
+.serverless
+swagger
+
+# yaml
+*.yaml
+*.yml
diff --git a/starters/serverless-framework-sqs-dynamodb/.prettierrc.json b/starters/serverless-framework-sqs-dynamodb/.prettierrc.json
new file mode 100644
index 000000000..065e5fbf1
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/.prettierrc.json
@@ -0,0 +1,8 @@
+{
+ "endOfLine": "lf",
+ "printWidth": 100,
+ "quoteProps": "consistent",
+ "singleQuote": true,
+ "trailingComma": "es5",
+ "useTabs": true
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/README.md b/starters/serverless-framework-sqs-dynamodb/README.md
new file mode 100644
index 000000000..04e9932cd
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/README.md
@@ -0,0 +1,234 @@
+# Serverless Framework, SQS, DynamoDB Kit
+
+## Table of contents
+
+- [Overview](#overview)
+- [Installation](#installation)
+ - [Pre-requisites](#pre-requisites)
+ - [CLI (Recommended)](#cli-recommended)
+ - [Manual](#manual)
+ - [Getting Started](#getting-started)
+- [Available Commands](#available-commands)
+ - [General Commands](#general-commands)
+ - [DynamoDB Commands](#dynamodb-commands)
+ - [Infrastructure Commands](#infrastructure-commands)
+- [DynamoDB](#dynamodb)
+- [Jest](#jest)
+- [Deployment](#deployment)
+
+## Overview
+
+A starter kit utilizing the Serverless Framework in conjunction with AWS DynamoDB & SQS. This kit aims to establish a baseline Serverless Framework project with reasonable configurations that is as-is deployable to AWS. It also provides a fully offline development mode for rapid iteration.
+
+## Installation
+
+### Pre-requisites
+
+- Node.js v16
+- npm or yarn or pnpm
+- Docker & docker-compose
+
+### CLI (Recommended)
+
+1. Init the project
+
+```shell
+npm create @this-dot/starter -- --kit serverless-framework-sqs-dynamodb
+```
+
+or
+
+```shell
+yarn create @this-dot/starter -- --kit serverless-framework-sqs-dynamodb
+```
+
+2. Follow the prompt to name your new project.
+3. `cd` into your project directory.
+
+### Manual
+
+1. Clone the starter.dev repo
+
+```shell
+git clone https://github.com/thisdot/starter.dev.git
+```
+
+2. Copy and rename the `starters/serverless-framework-sqs-dynamodb` directory to the name of your new project.
+3. `cd` into your project directory
+
+### Getting Started
+
+This README uses `npm` for commands. If you're using `yarn` or `pnpm`, utilize the equivalent version of the commands.
+
+1. Create a `.env` file. This is to support any variable in the Serverless Configuration being read from `env:` and test running:
+
+```bash
+cp .env.example .env
+```
+
+2. Run `npm i` to install deps
+3. Standup the project infrastructure using docker via:
+
+```bash
+npm run infrastructure:up
+```
+
+4. Sync database tables and seed the project via:
+
+```bash
+npm run db:sync
+npm run db:seed
+```
+
+5. Start the local development server:
+
+```bash
+npm start
+```
+
+6. Make changes and enjoy building your new backend!
+
+## Available Commands
+
+### General Commands
+
+- `build` bundles the project using the Serverless Framework's out of the box `package` command. The produced artifacts will be shipped to AWS on deployment. You can optionally pass `--analyze ` to run the bundle analyzer and visualize the results to understand your handler bundles.
+- `deploy` ships the project to the configured AWS account using the Serverless Framework CLI command.
+- `start` runs the `serverless-offline` provided server for local development and testing. Be sure to have the local docker infrastructure running to emulate the related services.
+- `test` runs `jest` under the hood.
+- `lint` runs `eslint` under the hood. You can use all the available eslint command line arguments. To lint the entire project, run `npm run lint .`, or equivalent. You can affix `--fix` to auto-correct linting issues that eslint can handle.
+- `format:check` runs prettier format checking on all project files.
+- `format:write` runs prettier format writing on all project files.
+
+### DynamoDB Commands
+
+- `db:sync` syncs all table definitions with DynamoDB instance to ensure resources are correct. Will output warnings for pre-existing tables.
+- `db:seed` adds seed data to the DynamoDB. See https://github.com/99x/serverless-dynamodb-local#seeding-sls-dynamodb-seed for more information.
+
+### Infrastructure Commands
+
+- `infrastructure:up` creates docker containers and runs them in the background. This should only be needed once during initial setup.
+- `infrastructure:down` deletes the docker containers.
+- `infrastructure:start` starts the docker containers.
+- `infrastructure:stop` stops the docker containers.
+
+## Project Structure
+
+- `db` contains seed files for database initialization using the `db:seed` script
+- `src/handlers` contains Lambda function handlers referenced by the Serverless Configuration.
+- `src/models` contains directories of different data models used by the project and exports all available methods.
+- `src/types` contains all TypeScript types for the project.
+- `src/utils` contains utility functions and wrappers.
+- `serverless.ts` - the Serverless Configuration
+
+**Note:** All tests are co-located with their implementation files.
+
+### Default API Routes
+
+This starter kit ships with a set of RESTful APIs. All routes are served via `http://localhost:4000` when using the local development mode.
+
+- `GET /healthcheck` returns the status of the databases
+- `GET /swagger` returns OpenAPI documentation for the API via Swagger
+- `POST /generate_job` generates a job to run on the queue
+
+**Technology CRUD**
+
+- `GET /technology` returns all technologies in the database
+- `POST /technology` creates a new technology record
+- `GET /technology/:id` get a technology by ID
+- `PUT /technology/:id` update a technology by ID
+- `DELETE /technology/:id` delete a technology by ID
+
+## Serverless Configuration
+
+This kit uses the TypeScript option for configuration. It is type checked using the `@serverless/typescript` definitions over the DefinitelyTyped definitions because DefinitelyTyped is currently behind on its definition. However, the `@serverless/typescript` types have known issues with certain fields, which are noted directly in the configuration.
+
+It is not compatible with the automated CI/CD of the Serverless Dashboard as they only support the default YAML format. You can read more about [setting up CI/CD using GitHub for this project on the This Dot blog](https://www.thisdot.co/blog/github-actions-for-serverless-framework-deployments).
+
+### Key Fields to Note
+
+#### `profile`
+
+This is left blank and will default to use your system configured AWS default profile. If you want to use a custom profile, set this field with the name of the profile used. This should ideally be selected based on the AWS organization used.
+
+#### `httpApi.cors`
+
+This setting was enabled out of the box to help with development quickly. Ideally, these CORS settings should be more restrictive in production environments. You can read more about [this option in the official docs](https://www.serverless.com/framework/docs/providers/aws/events/http-api/#cors-setup).
+
+#### Defined Stages
+
+This project comes configured with 3 default stages: `dev`, `staging`, `production`.
+
+- `dev` should be used for local development. If you need a shared dev environment for your team, it is recommended to add a new `local` stage for local dev and promote `dev` to the shared deployment stage value.
+- `staging` should be the pre-production staging environment for all changes.
+- `production` should be the production environment.
+
+#### esbuild
+
+This project uses [serverless-esbuild](https://www.npmjs.com/package/serverless-esbuild) over its webpack counterpart. The esbuild tool chain is generally faster and requires less dependencies to work out of the box. Currently, the tooling is configured to use `npm`. If you wish to use `yarn` or `pnpm`, please change the `packager` field in the configuration accordingly.
+
+#### Bundle Analyzer
+
+This project ships with the [serverless-analyze-bundle-plugin](https://www.npmjs.com/package/serverless-analyze-bundle-plugin) to allow you to visualize your Lambda bundles. This is an especially important factor when dealing with cold starts and should be monitored. To analyze a function bundle, run `npm run build -- --analyze `. This will give you a visualization of your function's dependencies and their sizes.
+
+#### File Patterns
+
+Barrel files are not used as they increase the size of the final output bundle due to [how esbuild interprets these types of files](https://github.com/evanw/esbuild/issues/2164). As such, it is recommended to split usable chunks of code into individually packaged files and directly imported for use.
+
+## DynamoDB
+
+This kit utilizes [AWS DynamoDB](https://aws.amazon.com/dynamodb/) for data persistence. It uses the [serverless-dynamodb-local](https://github.com/99x/serverless-dynamodb-local) plugin for table creation and seeding. The rest of the database is run via docker-compose using the `amazon/dynamodb-local` image.
+
+To help manage the database locally, the [`aaronshaf/dynamodb-admin`](https://github.com/aaronshaf/dynamodb-admin) image is used to provide an GUI interface for managing your database. After the infrastructure is stood up, you can visit the admin tool locally at http://localhost:8001/.
+
+### Defining New Tables
+
+To create a new table, define it via the `serverless.ts` resources section. It utilizes the [DynamoDB Cloudformation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html) to define data. Table names should be defined per environment through the Serverless `params` config. Once your table is defined, use the `npm run db:sync` command to create the table.
+
+### Defining New Seeds
+
+Seed files should be placed in the `db` directory. They can be formatted as JSON or raw AWS AttributeValues. The seed files should be added to the `custom.dynamodb.seed` section of the config. The `seed` key takes seed target groups which contain a list of source objects for your seed data. Target groups can be arbitrarily named per your needs, but the structure should be:
+
+```typescript
+seed: {
+ // example target group
+ core: {
+ sources: [
+ {
+ table: '',
+ sources: ['/.esbuild/'],
+ moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ setupFiles: ['./jest.setup.ts'],
+};
+
+export default jestConfig;
diff --git a/starters/serverless-framework-sqs-dynamodb/jest.setup.ts b/starters/serverless-framework-sqs-dynamodb/jest.setup.ts
new file mode 100644
index 000000000..6ac0123f2
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/jest.setup.ts
@@ -0,0 +1,5 @@
+import * as dotenv from 'dotenv';
+
+dotenv.config();
+
+jest.mock('ioredis', () => require('ioredis-mock'));
diff --git a/starters/serverless-framework-sqs-dynamodb/package.json b/starters/serverless-framework-sqs-dynamodb/package.json
new file mode 100644
index 000000000..5f61599cd
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/package.json
@@ -0,0 +1,76 @@
+{
+ "name": "serverless-framework-sqs-dynamodb",
+ "version": "1.0.0",
+ "description": "A starter kit utilizing the Serverless Framework in conjunction with AWS DynamoDB & SQS.",
+ "author": "Dustin Goodman",
+ "license": "MIT",
+ "keywords": [
+ "serverless-framework",
+ "dynamodb",
+ "sqs"
+ ],
+ "sideEffects": false,
+ "hasShowcase": false,
+ "scripts": {
+ "build": "sls package",
+ "deploy": "sls deploy",
+ "start": "SLS_DEBUG=* sls offline start --verbose",
+ "test": "jest --runInBand",
+ "lint": "eslint",
+ "format:check": "prettier --check .",
+ "format:write": "prettier --write .",
+ "db:sync": "sls dynamodb migrate",
+ "db:seed": "sls dynamodb seed",
+ "infrastructure:up": "docker compose up -d",
+ "infrastructure:down": "docker compose down",
+ "infrastructure:start": "docker compose start",
+ "infrastructure:stop": "docker compose stop"
+ },
+ "dependencies": {
+ "@aws-sdk/client-dynamodb": "3.241.0",
+ "@aws-sdk/client-sqs": "3.241.0",
+ "@aws-sdk/util-dynamodb": "3.241.0",
+ "cachified": "3.0.1",
+ "http-status-codes": "2.2.0",
+ "ioredis": "5.2.4",
+ "uuid": "9.0.0",
+ "zod": "3.20.2"
+ },
+ "devDependencies": {
+ "@serverless/typescript": "3.25.0",
+ "@types/aws-lambda": "8.10.109",
+ "@types/jest": "29.2.4",
+ "@types/node": "18.11.17",
+ "@types/serverless": "3.12.9",
+ "@types/uuid": "9.0.0",
+ "@typescript-eslint/eslint-plugin": "5.0.0",
+ "@typescript-eslint/parser": "5.47.0",
+ "aws-sdk-client-mock": "2.0.1",
+ "dotenv": "16.0.3",
+ "esbuild": "0.16.10",
+ "esbuild-node-externals": "1.6.0",
+ "esbuild-visualizer": "0.3.1",
+ "eslint": "8.0.1",
+ "eslint-config-prettier": "8.5.0",
+ "eslint-import-resolver-typescript": "3.5.2",
+ "eslint-plugin-import": "2.25.2",
+ "eslint-plugin-n": "15.0.0",
+ "eslint-plugin-promise": "6.0.0",
+ "i": "0.3.7",
+ "ioredis-mock": "8.2.2",
+ "jest": "29.3.1",
+ "npm": "9.2.0",
+ "prettier": "2.8.1",
+ "serverless": "3.26.0",
+ "serverless-analyze-bundle-plugin": "1.2.1",
+ "serverless-auto-swagger": "2.12.0",
+ "serverless-dynamodb-local": "0.2.40",
+ "serverless-esbuild": "1.34.0",
+ "serverless-offline": "12.0.3",
+ "serverless-offline-dynamodb-streams": "6.2.3",
+ "serverless-offline-sqs": "7.3.2",
+ "ts-jest": "29.0.3",
+ "ts-node": "10.9.1",
+ "typescript": "4.9.4"
+ }
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/serverless.ts b/starters/serverless-framework-sqs-dynamodb/serverless.ts
new file mode 100644
index 000000000..413c424cb
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/serverless.ts
@@ -0,0 +1,372 @@
+import type { AWS } from '@serverless/typescript';
+import { StatusCodes } from 'http-status-codes';
+
+const serverlessConfiguration: AWS = {
+ service: 'serverless-framework-sqs-dynamodb',
+ frameworkVersion: '3',
+ useDotenv: true,
+ plugins: [
+ 'serverless-auto-swagger',
+ 'serverless-esbuild',
+ 'serverless-analyze-bundle-plugin',
+ 'serverless-dynamodb-local',
+ 'serverless-offline-dynamodb-streams',
+ 'serverless-offline-sqs',
+ 'serverless-offline',
+ ],
+ custom: {
+ 'autoswagger': {
+ excludeStages: ['production'],
+ },
+ 'dynamodb': {
+ stages: ['dev'],
+ start: {
+ port: 8000,
+ convertEmptyValues: true,
+ noStart: true,
+ },
+ seed: {
+ core: {
+ sources: [
+ {
+ table: '${param:technologiesTable}',
+ sources: ['./db/technologies-seed.json'],
+ },
+ ],
+ },
+ },
+ },
+ 'esbuild': {
+ packager: 'npm',
+ plugins: './esbuild-plugins.ts',
+ bundle: true,
+ minify: true,
+ sourcemap: true,
+ },
+ 'serverless-offline': {
+ httpPort: 4000,
+ lambdaPort: 4002,
+ reloadHandler: true,
+ },
+ 'serverless-offline-dynamodb-streams': {
+ apiVersion: '2015-03-31',
+ endpoint: 'http://0.0.0.0:8000',
+ region: '${aws:region}',
+ accessKeyId: 'root',
+ secretAccessKey: 'root',
+ skipCacheInvalidation: false,
+ readInterval: 500,
+ },
+ 'serverless-offline-sqs': {
+ autoCreate: true,
+ apiVersion: '2012-11-05',
+ endpoint: 'http://0.0.0.0:9324',
+ region: 'us-east-1',
+ accessKeyId: 'root',
+ secretAccessKey: 'root',
+ skipCacheInvalidation: false,
+ },
+ },
+ package: {
+ individually: true,
+ patterns: ['src/handlers/*.ts', '!src/handlers/*.test.ts', '!node_modules/**'],
+ excludeDevDependencies: true,
+ },
+ params: {
+ production: {
+ technologiesTable: 'technologies-production',
+ },
+ staging: {
+ technologiesTable: 'technologies-staging',
+ },
+ dev: {
+ technologiesTable: 'technologies-dev',
+ },
+ },
+ provider: {
+ name: 'aws',
+ runtime: 'nodejs16.x',
+ // profile: '', // assumes default aws profile by default
+ stage: "${opt:stage, 'dev'}",
+ region: "${opt:region, 'us-east-1'}" as AWS['provider']['region'], // Temp fix. See https://github.com/serverless/typescript/issues/11.
+ memorySize: 512, // default: 1024MB
+ timeout: 29, // default: max allowable for Gateway
+ httpApi: {
+ // TODO: update to be more restrictive for real apps: https://www.serverless.com/framework/docs/providers/aws/events/http-api/#cors-setup
+ cors: true,
+ },
+ environment: {
+ REGION: '${aws:region}',
+ SLS_STAGE: '${sls:stage}',
+ DEFAULT_CACHE_TIME: '${env:DEFAULT_CACHE_TIME}',
+ REDIS_CACHE_URL: '${env:REDIS_CACHE_URL}',
+ // DynamoDB Tables
+ TECHNOLOGIES_TABLE: '${param:technologiesTable}',
+ },
+ iam: {
+ role: {
+ statements: [
+ {
+ Effect: 'Allow',
+ Action: ['lambda:InvokeFunction'],
+ Resource: 'arn:aws:lambda:*:*:*',
+ },
+ {
+ Effect: 'Allow',
+ Action: [
+ 'dynamodb:ListTables',
+ 'dynamodb:Scan',
+ 'dynamodb:Query',
+ 'dynamodb:GetItem',
+ 'dynamodb:PutItem',
+ 'dynamodb:UpdateItem',
+ ],
+ Resource: 'arn:aws:dynamodb:*:*:table/${param:technologiesTable}',
+ },
+ {
+ Effect: 'Allow',
+ Action: ['sqs:ListQueues', 'sqs:CreateQueue', 'sqs:SendMessage', 'sqs:GetQueueUrl'],
+ Resource: 'arn:aws:sqs:*:*:*',
+ },
+ ],
+ },
+ },
+ tracing: {
+ apiGateway: true,
+ lambda: true,
+ },
+ },
+ functions: {
+ healthcheck: {
+ handler: 'src/handlers/healthcheck.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/healthcheck',
+ method: 'get',
+ // @ts-expect-error Swagger Types not in main ts type
+ responseData: {
+ [StatusCodes.OK]: {
+ description: 'All systems ready to use',
+ },
+ [StatusCodes.SERVICE_UNAVAILABLE]: {
+ description: 'Systems are not operating correctly',
+ },
+ },
+ },
+ },
+ ],
+ },
+ technology_index: {
+ handler: 'src/handlers/technology_index.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/technology',
+ method: 'get',
+ // @ts-expect-error Swagger Types not in main ts type
+ responseData: {
+ [StatusCodes.OK]: {
+ description: 'Fetched Technologies Successfully',
+ bodyType: 'Technologies',
+ },
+ },
+ swaggerTags: ['Technology'],
+ },
+ },
+ ],
+ },
+ technology_create: {
+ handler: 'src/handlers/technology_create.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/technology',
+ method: 'post',
+ // @ts-expect-error Swagger Types not in main ts type
+ bodyType: 'TechnologyCreateBody',
+ responseData: {
+ [StatusCodes.CREATED]: {
+ description: 'Technology Successfully Created',
+ bodyType: 'Technology',
+ },
+ [StatusCodes.BAD_REQUEST]: {
+ description: 'Failed to create Technology',
+ bodyType: 'TechnologyCreateFormError',
+ },
+ [StatusCodes.INTERNAL_SERVER_ERROR]: {
+ description: 'Server Error',
+ },
+ },
+ swaggerTags: ['Technology'],
+ },
+ },
+ ],
+ },
+ technology_show: {
+ handler: 'src/handlers/technology_show.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/technology/{id}',
+ method: 'get',
+ // @ts-expect-error Swagger Types not in main ts type
+ responseData: {
+ [StatusCodes.OK]: {
+ description: 'Fetched Technology Successfully',
+ bodyType: 'Technology',
+ },
+ [StatusCodes.BAD_REQUEST]: {
+ description: 'Invalid Request',
+ },
+ [StatusCodes.NOT_FOUND]: {
+ description: 'Technology Not Found',
+ },
+ [StatusCodes.INTERNAL_SERVER_ERROR]: {
+ description: 'Server Error',
+ },
+ },
+ swaggerTags: ['Technology'],
+ },
+ },
+ ],
+ },
+ technology_update: {
+ handler: 'src/handlers/technology_update.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/technology/{id}',
+ method: 'put',
+ // @ts-expect-error Swagger Types not in main ts type
+ bodyType: 'TechnologyUpdateBody',
+ responseData: {
+ [StatusCodes.OK]: {
+ description: 'Technology Successfully Updated',
+ bodyType: 'Technology',
+ },
+ [StatusCodes.BAD_REQUEST]: {
+ description: 'Invalid Request',
+ bodyType: 'TechnologyUpdateFormError',
+ },
+ [StatusCodes.NOT_FOUND]: {
+ description: 'Technology Not Found',
+ },
+ [StatusCodes.INTERNAL_SERVER_ERROR]: {
+ description: 'Server Error',
+ },
+ },
+ swaggerTags: ['Technology'],
+ },
+ },
+ ],
+ },
+ technology_destroy: {
+ handler: 'src/handlers/technology_destroy.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/technology/{id}',
+ method: 'delete',
+ // @ts-expect-error Swagger Types not in main ts type
+ responseData: {
+ [StatusCodes.OK]: {
+ description: 'Technology Successfully Deleted',
+ bodyType: 'Technology',
+ },
+ [StatusCodes.BAD_REQUEST]: {
+ description: 'Invalid Request',
+ },
+ [StatusCodes.INTERNAL_SERVER_ERROR]: {
+ description: 'Server Error',
+ },
+ },
+ swaggerTags: ['Technology'],
+ },
+ },
+ ],
+ },
+ generate_job: {
+ handler: 'src/handlers/generate_job.handler',
+ events: [
+ {
+ httpApi: {
+ path: '/generate_job',
+ method: 'post',
+ // @ts-expect-error Swagger Types not in main ts type
+ responseData: {
+ [StatusCodes.CREATED]: {
+ description: 'Job Generated',
+ },
+ [StatusCodes.BAD_REQUEST]: {
+ description: 'Failed to generate job',
+ },
+ },
+ },
+ },
+ ],
+ },
+ example_job_processor: {
+ handler: 'src/handlers/example_job_processor.handler',
+ events: [
+ {
+ sqs: {
+ arn: {
+ 'Fn::GetAtt': ['ExampleQueue', 'Arn'],
+ },
+ },
+ },
+ ],
+ },
+ example_stream_processor: {
+ handler: 'src/handlers/example_stream_processor.handler',
+ events: [
+ {
+ stream: {
+ type: 'dynamodb',
+ arn: {
+ 'Fn::GetAtt': ['technologiesTable', 'StreamArn'],
+ },
+ },
+ },
+ ],
+ },
+ },
+ resources: {
+ Resources: {
+ technologiesTable: {
+ Type: 'AWS::DynamoDB::Table',
+ Properties: {
+ TableName: '${param:technologiesTable}',
+ AttributeDefinitions: [
+ {
+ AttributeName: 'id',
+ AttributeType: 'S',
+ },
+ ],
+ KeySchema: [
+ {
+ AttributeName: 'id',
+ KeyType: 'HASH',
+ },
+ ],
+ ProvisionedThroughput: {
+ ReadCapacityUnits: 1,
+ WriteCapacityUnits: 1,
+ },
+ StreamSpecification: {
+ StreamViewType: 'NEW_AND_OLD_IMAGES',
+ },
+ },
+ },
+ ExampleQueue: {
+ Type: 'AWS::SQS::Queue',
+ Properties: {
+ QueueName: 'ExampleQueue',
+ },
+ },
+ },
+ },
+};
+
+module.exports = serverlessConfiguration;
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/example_job_processor.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_job_processor.test.ts
new file mode 100644
index 000000000..16981912a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_job_processor.test.ts
@@ -0,0 +1,38 @@
+import type { Context, Callback, SQSEvent } from 'aws-lambda';
+import { handler } from './example_job_processor';
+
+describe('demo', () => {
+ let subject: Awaited>;
+ let logMock: jest.SpyInstance;
+
+ beforeAll(async () => {
+ logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
+ subject = await handler(
+ {
+ Records: [
+ {
+ body: 'Hello',
+ },
+ {
+ body: 'World',
+ },
+ ],
+ } as SQSEvent,
+ {} as Context,
+ {} as Callback
+ );
+ });
+
+ afterAll(() => {
+ logMock.mockReset();
+ });
+
+ it('processes both messages', () => {
+ expect(console.log).toHaveBeenCalledWith('"Hello"');
+ expect(console.log).toHaveBeenCalledWith('"World"');
+ });
+
+ it('returns nothing', () => {
+ expect(subject).toBeUndefined();
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/example_job_processor.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_job_processor.ts
new file mode 100644
index 000000000..e2afd8f5f
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_job_processor.ts
@@ -0,0 +1,15 @@
+import type { SQSHandler, SQSRecord } from 'aws-lambda';
+
+const recordHandler = async (record: SQSRecord) => {
+ console.log(JSON.stringify(record.body));
+};
+
+export const handler: SQSHandler = async (event) => {
+ console.log('Example Job Processor Handler initiated');
+
+ // Ensuring we await on all the promises is super important to avoid
+ // accidentally killing the lambda prior to processing being completed.
+ await Promise.all(event.Records.map(recordHandler));
+
+ console.log('Example Job Processor Handler completed');
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/example_stream_processor.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_stream_processor.test.ts
new file mode 100644
index 000000000..64763354d
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_stream_processor.test.ts
@@ -0,0 +1,87 @@
+import type { Context, Callback, DynamoDBStreamEvent } from 'aws-lambda';
+import { handler } from './example_stream_processor';
+
+describe('demo', () => {
+ let subject: Awaited>;
+ let logMock: jest.SpyInstance;
+
+ beforeAll(async () => {
+ logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
+ subject = await handler(
+ {
+ Records: [
+ {
+ eventName: 'UNKNOWN',
+ },
+ {
+ eventName: 'INSERT',
+ dynamodb: {
+ NewImage: {
+ id: 1,
+ name: 'Test',
+ },
+ },
+ },
+ {
+ eventName: 'MODIFY',
+ dynamodb: {
+ OldImage: {
+ id: 1,
+ name: 'Test',
+ },
+ NewImage: {
+ id: 1,
+ name: 'Test 2',
+ },
+ },
+ },
+ {
+ eventName: 'REMOVE',
+ dynamodb: {
+ OldImage: {
+ id: 1,
+ name: 'Test 2',
+ },
+ },
+ },
+ ],
+ } as DynamoDBStreamEvent,
+ {} as Context,
+ {} as Callback
+ );
+ });
+
+ afterAll(() => {
+ logMock.mockReset();
+ });
+
+ it('process INSERT messages', () => {
+ expect(console.log).toHaveBeenCalledWith('Inserted Record', {
+ id: 1,
+ name: 'Test',
+ });
+ });
+
+ it('process MODIFY messages', () => {
+ expect(console.log).toHaveBeenCalledWith('Updated Record');
+ expect(console.log).toHaveBeenCalledWith('New Values', {
+ id: 1,
+ name: 'Test 2',
+ });
+ expect(console.log).toHaveBeenCalledWith('Old Values', {
+ id: 1,
+ name: 'Test',
+ });
+ });
+
+ it('process REMOVE messages', () => {
+ expect(console.log).toHaveBeenCalledWith('Removed Record', {
+ id: 1,
+ name: 'Test 2',
+ });
+ });
+
+ it('returns nothing', () => {
+ expect(subject).toBeUndefined();
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/example_stream_processor.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_stream_processor.ts
new file mode 100644
index 000000000..c6e6cdf7d
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/example_stream_processor.ts
@@ -0,0 +1,26 @@
+import type { DynamoDBStreamHandler, DynamoDBRecord } from 'aws-lambda';
+
+const recordHandler = async (record: DynamoDBRecord) => {
+ if (!record.dynamodb) {
+ return;
+ }
+
+ if (record.eventName === 'INSERT') {
+ console.log('Inserted Record', record.dynamodb.NewImage);
+ } else if (record.eventName === 'MODIFY') {
+ console.log('Updated Record');
+ console.log('New Values', record.dynamodb.NewImage);
+ console.log('Old Values', record.dynamodb.OldImage);
+ } else if (record.eventName === 'REMOVE') {
+ console.log('Removed Record', record.dynamodb.OldImage);
+ }
+};
+
+export const handler: DynamoDBStreamHandler = async (event) => {
+ console.log('Example Stream Processor Handler initiated');
+
+ // Ensuring we await on all the promises is super important to avoid
+ // accidentally killing the lambda prior to processing being completed.
+ await Promise.all(event.Records.map(recordHandler));
+ console.log('Example Stream Processor Handler completed');
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/generate_job.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/generate_job.test.ts
new file mode 100644
index 000000000..23aae86df
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/generate_job.test.ts
@@ -0,0 +1,77 @@
+import type { APIGatewayProxyResult, APIGatewayProxyEvent, Context, Callback } from 'aws-lambda';
+import { sendMessage } from '@/utils/sqs/sendMessage';
+import { handler } from './generate_job';
+
+jest.mock('@/utils/sqs/sendMessage');
+
+describe('POST /generate_job', () => {
+ let subject: APIGatewayProxyResult;
+ let mathRandomMock: jest.SpyInstance;
+
+ const sendMessageMock = jest.mocked(sendMessage);
+
+ beforeAll(() => {
+ mathRandomMock = jest.spyOn(global.Math, 'random').mockReturnValue(0.11);
+ });
+
+ afterAll(() => {
+ mathRandomMock.mockRestore();
+ jest.clearAllMocks();
+ });
+
+ describe('when the message is sent successfully', () => {
+ beforeAll(async () => {
+ sendMessageMock.mockResolvedValue({
+ success: true,
+ data: {
+ MessageId: '123456789',
+ },
+ });
+ subject = (await handler(
+ {} as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(() => {
+ sendMessageMock.mockClear();
+ });
+
+ it('returns a 201 status code', () => {
+ expect(subject.statusCode).toBe(201);
+ });
+
+ it('returns the returned messaged', () => {
+ expect(JSON.parse(subject.body)).toEqual({
+ MessageId: '123456789',
+ });
+ });
+ });
+
+ describe('when the message is not sent successfully', () => {
+ beforeAll(async () => {
+ sendMessageMock.mockResolvedValue({
+ success: false,
+ data: 'bad request',
+ });
+ subject = (await handler(
+ {} as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(() => {
+ sendMessageMock.mockClear();
+ });
+
+ it('returns a 400 status code', () => {
+ expect(subject.statusCode).toBe(400);
+ });
+
+ it('returns the returned messaged', () => {
+ expect(JSON.parse(subject.body)).toEqual('bad request');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/generate_job.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/generate_job.ts
new file mode 100644
index 000000000..5deed8fdc
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/generate_job.ts
@@ -0,0 +1,15 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { StatusCodes } from 'http-status-codes';
+import { sendMessage } from '@/utils/sqs/sendMessage';
+
+export const handler: APIGatewayProxyHandler = async () => {
+ const resp = await sendMessage('ExampleQueue', {
+ id: Math.ceil(Math.random() * 100),
+ message: 'Hello World!',
+ });
+
+ return {
+ statusCode: resp.success ? StatusCodes.CREATED : StatusCodes.BAD_REQUEST,
+ body: JSON.stringify(resp.data),
+ };
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/healthcheck.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/healthcheck.test.ts
new file mode 100644
index 000000000..71477d871
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/healthcheck.test.ts
@@ -0,0 +1,102 @@
+import type { APIGatewayProxyEvent, Context, Callback, APIGatewayProxyResult } from 'aws-lambda';
+import { mockClient } from 'aws-sdk-client-mock';
+import { ListTablesCommand } from '@aws-sdk/client-dynamodb';
+import { ListQueuesCommand } from '@aws-sdk/client-sqs';
+import { Redis } from 'ioredis';
+import { getClient as getDynamodbClient } from '@/utils/dynamodb/getClient';
+import { getClient as getSqsClient } from '@/utils/sqs/getClient';
+import * as GetRedisClient from '@/utils/redis/getClient';
+import { handler } from './healthcheck';
+
+describe('GET /healthcheck', () => {
+ let subject: APIGatewayProxyResult;
+ let ddbMock: ReturnType;
+ let sqsMock: ReturnType;
+
+ describe('when all services are functional', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getDynamodbClient());
+ sqsMock = mockClient(getSqsClient());
+
+ ddbMock.on(ListTablesCommand).resolves({
+ TableNames: ['technologies-test'],
+ });
+ sqsMock.on(ListQueuesCommand).resolves({
+ QueueUrls: ['http://localhost:9324/000000000000/ExampleQueue'],
+ });
+
+ jest.spyOn(GetRedisClient, 'getClient').mockImplementation(async () => {
+ return {
+ status: 'ready',
+ } as Redis;
+ });
+
+ subject = (await handler(
+ {} as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ sqsMock.restore();
+ jest.resetAllMocks();
+ });
+
+ it('returns a 200 statusCode', () => {
+ expect(subject.statusCode).toBe(200);
+ });
+
+ it('returns status', () => {
+ expect(JSON.parse(subject.body)).toEqual(
+ expect.objectContaining({
+ dynamodbStatus: `Connected with tables: ${['technologies-test']}`,
+ sqsStatus: `Connected with queues: ${[
+ 'http://localhost:9324/000000000000/ExampleQueue',
+ ]}`,
+ cacheRedisStatus: 'ready',
+ })
+ );
+ });
+ });
+
+ describe('when a service is failing', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getDynamodbClient());
+ sqsMock = mockClient(getSqsClient());
+
+ ddbMock.on(ListTablesCommand).rejects('mock error');
+ sqsMock.on(ListQueuesCommand).resolves({
+ QueueUrls: ['http://localhost:9324/000000000000/ExampleQueue'],
+ });
+
+ jest.spyOn(GetRedisClient, 'getClient').mockImplementation(async () => {
+ return {
+ status: 'ready',
+ } as Redis;
+ });
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ subject = (await handler(
+ {} as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ sqsMock.restore();
+ jest.resetAllMocks();
+ });
+
+ it('returns a 503 statusCode', () => {
+ expect(subject.statusCode).toBe(503);
+ });
+
+ it('returns status', () => {
+ expect(subject.body).toEqual('mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/healthcheck.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/healthcheck.ts
new file mode 100644
index 000000000..04fb4f748
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/healthcheck.ts
@@ -0,0 +1,39 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import Redis from 'ioredis';
+import { StatusCodes } from 'http-status-codes';
+import { listTables } from '@/utils/dynamodb/listTables';
+import { listQueues } from '@/utils/sqs/listQueues';
+import { getClient as getRedisClient } from '@/utils/redis/getClient';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+
+const checkRedisConnection = (client: Redis) => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(client.status);
+ }, 100);
+ });
+};
+
+export const handler: APIGatewayProxyHandler = async () => {
+ try {
+ const [dynamodbTables, sqsQueues, cacheRedisConnected] = await Promise.all([
+ listTables(),
+ listQueues(),
+ checkRedisConnection(await getRedisClient('cache', process.env.REDIS_CACHE_URL)),
+ ]);
+
+ return {
+ statusCode: StatusCodes.OK,
+ body: JSON.stringify({
+ dynamodbStatus: `Connected with tables: ${dynamodbTables}`,
+ sqsStatus: `Connected with queues: ${sqsQueues}`,
+ cacheRedisStatus: cacheRedisConnected,
+ }),
+ };
+ } catch (err) {
+ return {
+ statusCode: StatusCodes.SERVICE_UNAVAILABLE,
+ body: getErrorMessage(err),
+ };
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_create.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_create.test.ts
new file mode 100644
index 000000000..f4eb77e23
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_create.test.ts
@@ -0,0 +1,106 @@
+import type { APIGatewayProxyResult, APIGatewayProxyEvent, Context, Callback } from 'aws-lambda';
+import { PutItemCommand, ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-dynamodb';
+import { AwsStub, mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import * as technologyCreate from '@/models/technology/create';
+import { handler } from './technology_create';
+
+describe('POST /technology', () => {
+ let subject: APIGatewayProxyResult;
+ let ddbMock: AwsStub;
+
+ describe('when record is created', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(PutItemCommand).resolves({
+ Attributes: undefined,
+ });
+ subject = (await handler(
+ {
+ body: JSON.stringify({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Valid Test',
+ }),
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ ddbMock.restore();
+ });
+
+ it('returns 201 status', () => {
+ expect(subject.statusCode).toEqual(201);
+ });
+
+ it('returns the created record', () => {
+ expect(JSON.parse(subject.body)).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: expect.any(String),
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Valid Test',
+ });
+ });
+ });
+
+ describe('when invalid inputs', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ subject = (await handler(
+ {} as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ });
+
+ it('returns 400 status', () => {
+ expect(subject.statusCode).toEqual(400);
+ });
+
+ it('returns form error', () => {
+ const parsedResp = JSON.parse(subject.body);
+ expect(parsedResp).toHaveProperty('formErrors');
+ expect(parsedResp).toHaveProperty('fieldErrors');
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ jest.spyOn(technologyCreate, 'create').mockRejectedValue('fail');
+ subject = (await handler(
+ {
+ body: JSON.stringify({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Valid Test',
+ }),
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ });
+
+ it('returns 500 status', () => {
+ expect(subject.statusCode).toEqual(500);
+ });
+
+ it('returns server error message', () => {
+ expect(subject.body).toEqual(expect.stringContaining('Server Error:'));
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_create.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_create.ts
new file mode 100644
index 000000000..59f35a0d2
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_create.ts
@@ -0,0 +1,26 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { ZodError } from 'zod';
+import { StatusCodes } from 'http-status-codes';
+import { TechnologyCreateSchema } from '@/types/technology';
+import { create as createTechnology } from '@/models/technology/create';
+import { responseHelper } from '@/utils/responseHelper/responseHelper';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+
+export const handler: APIGatewayProxyHandler = async (event) => {
+ try {
+ const payload = event.body ? JSON.parse(event.body) : null;
+ TechnologyCreateSchema.parse(payload);
+ const technology = await createTechnology(payload);
+
+ return responseHelper(StatusCodes.CREATED, technology);
+ } catch (err) {
+ console.error(err);
+ if (err instanceof ZodError) {
+ return responseHelper(StatusCodes.BAD_REQUEST, err.flatten());
+ }
+ return responseHelper(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Server Error: ${getErrorMessage(err)}`
+ );
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_destroy.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_destroy.test.ts
new file mode 100644
index 000000000..de15f7b43
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_destroy.test.ts
@@ -0,0 +1,104 @@
+import type { APIGatewayProxyResult, APIGatewayProxyEvent, Context, Callback } from 'aws-lambda';
+import { DeleteItemCommand, ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-dynamodb';
+import { AwsStub, mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import * as technologyDestroy from '@/models/technology/destroy';
+import { handler } from './technology_destroy';
+
+describe('DELETE /technology/:id', () => {
+ let subject: APIGatewayProxyResult;
+ let ddbMock: AwsStub;
+
+ describe('when no id provided', () => {
+ beforeAll(async () => {
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: null,
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ it('returns 400 status', () => {
+ expect(subject.statusCode).toEqual(400);
+ });
+
+ it('returns bad request message', () => {
+ expect(subject.body).toEqual('Bad Request: no id provided');
+ });
+ });
+
+ describe('when record maybe deleted', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(DeleteItemCommand).resolves({
+ Attributes: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ ddbMock.restore();
+ });
+
+ it('returns 200 status', () => {
+ expect(subject.statusCode).toEqual(200);
+ });
+
+ it('returns deleted object if it exists', () => {
+ expect(JSON.parse(subject.body)).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ jest.spyOn(technologyDestroy, 'destroy').mockRejectedValue('fail');
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ });
+
+ it('returns 500 status', () => {
+ expect(subject.statusCode).toEqual(500);
+ });
+
+ it('returns server error message', () => {
+ expect(subject.body).toEqual(expect.stringContaining('Server Error:'));
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_destroy.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_destroy.ts
new file mode 100644
index 000000000..77318857e
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_destroy.ts
@@ -0,0 +1,23 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { StatusCodes } from 'http-status-codes';
+import { destroy as destroyTechnology } from '@/models/technology/destroy';
+import { responseHelper } from '@/utils/responseHelper/responseHelper';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+
+export const handler: APIGatewayProxyHandler = async (event) => {
+ const id = event.pathParameters?.id;
+ if (!id) {
+ return responseHelper(StatusCodes.BAD_REQUEST, 'Bad Request: no id provided');
+ }
+
+ try {
+ const technology = await destroyTechnology(id);
+ return responseHelper(StatusCodes.OK, technology);
+ } catch (err) {
+ console.error(err);
+ return responseHelper(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Server Error: ${getErrorMessage(err)}`
+ );
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_index.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_index.test.ts
new file mode 100644
index 000000000..ba45d702b
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_index.test.ts
@@ -0,0 +1,34 @@
+import type { APIGatewayProxyResult, APIGatewayProxyEvent, Context, Callback } from 'aws-lambda';
+import { ScanCommand, ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-dynamodb';
+import { AwsStub, mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { handler } from './technology_index';
+
+describe('GET /technology', () => {
+ let subject: APIGatewayProxyResult;
+ let ddbMock: AwsStub;
+
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(ScanCommand).resolves({
+ Items: [],
+ });
+ subject = (await handler(
+ {} as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ });
+
+ it('returns 200 status', () => {
+ expect(subject.statusCode).toEqual(200);
+ });
+
+ it('returns a list of technologies', () => {
+ expect(JSON.parse(subject.body)).toEqual([]);
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_index.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_index.ts
new file mode 100644
index 000000000..e0d14176e
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_index.ts
@@ -0,0 +1,9 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { StatusCodes } from 'http-status-codes';
+import { getAll as getAllTechnology } from '@/models/technology/getAll';
+import { responseHelper } from '@/utils/responseHelper/responseHelper';
+
+export const handler: APIGatewayProxyHandler = async () => {
+ const technologies = await getAllTechnology();
+ return responseHelper(StatusCodes.OK, technologies);
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_show.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_show.test.ts
new file mode 100644
index 000000000..52df9431a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_show.test.ts
@@ -0,0 +1,146 @@
+import type { APIGatewayProxyResult, APIGatewayProxyEvent, Context, Callback } from 'aws-lambda';
+import { GetItemCommand, ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-dynamodb';
+import { AwsStub, mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { removeFromCache } from '@/utils/cache/removeFromCache';
+import * as technologyGet from '@/models/technology/get';
+import { getCacheKey } from '@/models/technology/getCacheKey';
+import { handler } from './technology_show';
+
+describe('GET /technology/:id', () => {
+ let subject: APIGatewayProxyResult;
+ let ddbMock: AwsStub;
+
+ beforeAll(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('when no id provided', () => {
+ beforeAll(async () => {
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: null,
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ it('returns 400 status', () => {
+ expect(subject.statusCode).toEqual(400);
+ });
+
+ it('returns bad request message', () => {
+ expect(subject.body).toEqual('Bad Request: no id provided');
+ });
+ });
+
+ describe('when record not found', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolves({
+ Item: undefined,
+ });
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ ddbMock.restore();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ });
+
+ it('returns 404 status', () => {
+ expect(subject.statusCode).toEqual(404);
+ });
+
+ it('returns null', () => {
+ expect(JSON.parse(subject.body)).toBeNull();
+ });
+ });
+
+ describe('when record is found', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolves({
+ Item: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ ddbMock.restore();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ });
+
+ it('returns 200 status', () => {
+ expect(subject.statusCode).toEqual(200);
+ });
+
+ it('returns the requested record', () => {
+ expect(JSON.parse(subject.body)).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ jest.spyOn(technologyGet, 'get').mockRejectedValue('fail');
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ });
+
+ it('returns 500 status', () => {
+ expect(subject.statusCode).toEqual(500);
+ });
+
+ it('returns server error message', () => {
+ expect(subject.body).toEqual(expect.stringContaining('Server Error:'));
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_show.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_show.ts
new file mode 100644
index 000000000..6c7ba4c0b
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_show.ts
@@ -0,0 +1,26 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { StatusCodes } from 'http-status-codes';
+import { get as getTechnology } from '@/models/technology/get';
+import { responseHelper } from '@/utils/responseHelper/responseHelper';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+
+export const handler: APIGatewayProxyHandler = async (event) => {
+ const id = event.pathParameters?.id;
+ if (!id) {
+ return responseHelper(StatusCodes.BAD_REQUEST, 'Bad Request: no id provided');
+ }
+
+ try {
+ const technology = await getTechnology(id);
+ if (!technology) {
+ return responseHelper(StatusCodes.NOT_FOUND, null);
+ }
+ return responseHelper(StatusCodes.OK, technology);
+ } catch (err) {
+ console.error(err);
+ return responseHelper(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Server Error: ${getErrorMessage(err)}`
+ );
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_update.test.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_update.test.ts
new file mode 100644
index 000000000..e39a32329
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_update.test.ts
@@ -0,0 +1,198 @@
+import type { APIGatewayProxyResult, APIGatewayProxyEvent, Context, Callback } from 'aws-lambda';
+import {
+ GetItemCommand,
+ PutItemCommand,
+ ServiceInputTypes,
+ ServiceOutputTypes,
+} from '@aws-sdk/client-dynamodb';
+import { AwsStub, mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { removeFromCache } from '@/utils/cache/removeFromCache';
+import * as technologyUpdate from '@/models/technology/update';
+import { getCacheKey } from '@/models/technology/getCacheKey';
+import { handler } from './technology_update';
+
+describe('PUT /technology/:id', () => {
+ let subject: APIGatewayProxyResult;
+ let ddbMock: AwsStub;
+
+ describe('when no id provided', () => {
+ beforeAll(async () => {
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: null,
+ },
+ body: JSON.stringify({
+ displayName: 'Valid Test',
+ }),
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ it('returns 400 status', () => {
+ expect(subject.statusCode).toEqual(400);
+ });
+
+ it('returns bad request message', () => {
+ expect(subject.body).toEqual('Bad Request: no id provided');
+ });
+ });
+
+ describe('when record not found', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolves({
+ Item: undefined,
+ });
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ body: JSON.stringify({
+ displayName: 'Valid Test',
+ }),
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ ddbMock.restore();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ jest.resetAllMocks();
+ });
+
+ it('returns 404 status', () => {
+ expect(subject.statusCode).toEqual(404);
+ });
+
+ it('returns null', () => {
+ expect(JSON.parse(subject.body)).toBeNull();
+ });
+ });
+
+ describe('when record is found', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolvesOnce({
+ Item: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ ddbMock.on(PutItemCommand).resolves({
+ Attributes: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Valid Test' },
+ },
+ });
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ body: JSON.stringify({
+ displayName: 'Valid Test',
+ }),
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ ddbMock.restore();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ });
+
+ it('returns 200 status', () => {
+ expect(subject.statusCode).toEqual(200);
+ });
+
+ it('returns the updated record', () => {
+ expect(JSON.parse(subject.body)).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Valid Test',
+ });
+ });
+ });
+
+ describe('when invalid inputs', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ });
+
+ it('returns 400 status', () => {
+ expect(subject.statusCode).toEqual(400);
+ });
+
+ it('returns form error', () => {
+ const parsedResp = JSON.parse(subject.body);
+ expect(parsedResp).toHaveProperty('formErrors');
+ expect(parsedResp).toHaveProperty('fieldErrors');
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ jest.spyOn(technologyUpdate, 'update').mockRejectedValue('fail');
+ subject = (await handler(
+ {
+ pathParameters: {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ },
+ body: JSON.stringify({
+ displayName: 'Error Test',
+ }),
+ } as unknown as APIGatewayProxyEvent,
+ {} as Context,
+ {} as Callback
+ )) as APIGatewayProxyResult;
+ });
+
+ afterAll(async () => {
+ jest.restoreAllMocks();
+ await removeFromCache(getCacheKey('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff'));
+ });
+
+ it('returns 500 status', () => {
+ expect(subject.statusCode).toEqual(500);
+ });
+
+ it('returns server error message', () => {
+ expect(subject.body).toEqual(expect.stringContaining('Server Error:'));
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_update.ts b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_update.ts
new file mode 100644
index 000000000..0f92f5d0f
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/handlers/technology_update.ts
@@ -0,0 +1,34 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { ZodError } from 'zod';
+import { StatusCodes } from 'http-status-codes';
+import { TechnologyUpdateSchema } from '@/types/technology';
+import { update as updateTechnology } from '@/models/technology/update';
+import { responseHelper } from '@/utils/responseHelper/responseHelper';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+
+export const handler: APIGatewayProxyHandler = async (event) => {
+ const id = event.pathParameters?.id;
+ if (!id) {
+ return responseHelper(StatusCodes.BAD_REQUEST, 'Bad Request: no id provided');
+ }
+
+ try {
+ const payload = event.body ? JSON.parse(event.body) : null;
+ TechnologyUpdateSchema.parse(payload);
+ const technology = await updateTechnology(id, payload);
+ if (!technology) {
+ return responseHelper(StatusCodes.NOT_FOUND, null);
+ }
+
+ return responseHelper(StatusCodes.OK, technology);
+ } catch (err) {
+ console.error(err);
+ if (err instanceof ZodError) {
+ return responseHelper(StatusCodes.BAD_REQUEST, err.flatten());
+ }
+ return responseHelper(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Server Error: ${getErrorMessage(err)}`
+ );
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/create.test.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/create.test.ts
new file mode 100644
index 000000000..7c5c5a4b1
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/create.test.ts
@@ -0,0 +1,56 @@
+import { PutItemCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { create } from './create';
+
+describe('technology.create()', () => {
+ let subject: Awaited>;
+
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when successfully creates the record', () => {
+ beforeAll(async () => {
+ ddbMock.on(PutItemCommand).resolves({
+ Attributes: undefined,
+ });
+ subject = await create({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+
+ it('returns the newly created technology', () => {
+ expect(subject).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: expect.any(String),
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+ });
+
+ describe('when fails to create the record', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ ddbMock.on(PutItemCommand).rejects('mock error');
+ subject = await create({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/create.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/create.ts
new file mode 100644
index 000000000..9a212c961
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/create.ts
@@ -0,0 +1,19 @@
+import { v4 as uuidv4 } from 'uuid';
+import { TechnologyCreate } from '@/types/technology';
+import { putItem } from '@/utils/dynamodb/putItem';
+import { addToCache } from '@/utils/cache/addToCache';
+import { getCacheKey } from './getCacheKey';
+
+export const create = async (payload: TechnologyCreate) => {
+ const newTechnology = {
+ id: uuidv4(),
+ ...payload,
+ };
+ const didPersist = await putItem(process.env.TECHNOLOGIES_TABLE, newTechnology);
+ if (didPersist) {
+ await addToCache(getCacheKey(newTechnology.id), newTechnology);
+ return newTechnology;
+ }
+
+ return null;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/destroy.test.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/destroy.test.ts
new file mode 100644
index 000000000..48e9c83f1
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/destroy.test.ts
@@ -0,0 +1,39 @@
+import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { destroy } from './destroy';
+
+describe('technology.destroy()', () => {
+ let subject: Awaited>;
+
+ const ddbMock = mockClient(getClient());
+
+ beforeAll(async () => {
+ ddbMock.on(DeleteItemCommand).resolves({
+ Attributes: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ subject = await destroy('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff');
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ it('returns the deleted technology', () => {
+ expect(subject).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/destroy.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/destroy.ts
new file mode 100644
index 000000000..20a3e650a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/destroy.ts
@@ -0,0 +1,9 @@
+import { removeFromCache } from '@/utils/cache/removeFromCache';
+import { deleteItem } from '@/utils/dynamodb/deleteItem';
+import { getCacheKey } from './getCacheKey';
+
+export const destroy = async (key: string) => {
+ const item = await deleteItem(process.env.TECHNOLOGIES_TABLE, { id: key });
+ await removeFromCache(getCacheKey(key));
+ return item;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/get.test.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/get.test.ts
new file mode 100644
index 000000000..c1d51e467
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/get.test.ts
@@ -0,0 +1,39 @@
+import { GetItemCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { get } from './get';
+
+describe('technology.get()', () => {
+ let subject: Awaited>;
+
+ const ddbMock = mockClient(getClient());
+
+ beforeAll(async () => {
+ ddbMock.on(GetItemCommand).resolves({
+ Item: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ subject = await get('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff');
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ it('returns the requested technology', () => {
+ expect(subject).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/get.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/get.ts
new file mode 100644
index 000000000..8a20195f8
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/get.ts
@@ -0,0 +1,17 @@
+import { useCache } from '@/utils/cache/useCache';
+import { getItem } from '@/utils/dynamodb/getItem';
+import { TechnologySchema } from '@/types/technology';
+import { getCacheKey } from './getCacheKey';
+
+export const get = async (key: string) => {
+ return useCache({
+ key: getCacheKey(key),
+ getFreshValue: async () => {
+ const item = await getItem(process.env.TECHNOLOGIES_TABLE, { id: key });
+ return item;
+ },
+ checkValue(value: unknown) {
+ TechnologySchema.parse(value);
+ },
+ });
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/getAll.test.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/getAll.test.ts
new file mode 100644
index 000000000..916a3b581
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/getAll.test.ts
@@ -0,0 +1,70 @@
+import { ScanCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { getAll } from './getAll';
+
+describe('technology.getAll()', () => {
+ let subject: Awaited>;
+
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when no items in table', () => {
+ beforeAll(async () => {
+ ddbMock.on(ScanCommand).resolves({
+ Items: [
+ {
+ description: {
+ S: 'Fully managed message queuing for microservices, distributed systems, and serverless applications',
+ },
+ id: { S: 'ca9085fa-45a6-4d56-b6ee-8d6c59bebbfa' },
+ websiteUrl: { S: 'https://aws.amazon.com/sqs/' },
+ displayName: { S: 'AWS SQS' },
+ },
+ {
+ description: {
+ S: 'Fast, flexible NoSQL database service for single-digit millisecond performance at any scale',
+ },
+ id: { S: 'dfb3378f-a991-4e45-8e23-59e27768f96f' },
+ websiteUrl: { S: 'https://aws.amazon.com/dynamodb/' },
+ displayName: { S: 'AWS DynamoDB' },
+ },
+ {
+ description: {
+ S: 'All-in-one development solution for auto-scaling apps on AWS Lambda',
+ },
+ id: { S: '629202fd-00bc-46f2-9f67-3ddeb149b931' },
+ websiteUrl: { S: 'https://www.serverless.com/' },
+ displayName: { S: 'Serverless Framework' },
+ },
+ ],
+ });
+ subject = await getAll();
+ });
+
+ it('returns the requested technology', () => {
+ expect(subject).toHaveLength(3);
+ const actual = subject.map((tech) => tech.displayName);
+ expect(actual).toContain('AWS SQS');
+ expect(actual).toContain('AWS DynamoDB');
+ expect(actual).toContain('Serverless Framework');
+ });
+ });
+
+ describe('when items in table', () => {
+ beforeAll(async () => {
+ ddbMock.on(ScanCommand).resolves({
+ Items: undefined,
+ });
+ subject = await getAll();
+ });
+
+ it('returns the requested technology', () => {
+ expect(subject).toHaveLength(0);
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/getAll.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/getAll.ts
new file mode 100644
index 000000000..943d98455
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/getAll.ts
@@ -0,0 +1,5 @@
+import { scan } from '@/utils/dynamodb/scan';
+
+export const getAll = async () => {
+ return await scan(process.env.TECHNOLOGIES_TABLE);
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/getCacheKey.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/getCacheKey.ts
new file mode 100644
index 000000000..abbeb62c4
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/getCacheKey.ts
@@ -0,0 +1,3 @@
+export function getCacheKey(key: string) {
+ return `technology-${key}`;
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/update.test.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/update.test.ts
new file mode 100644
index 000000000..0847e8d12
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/update.test.ts
@@ -0,0 +1,113 @@
+import {
+ GetItemCommand,
+ PutItemCommand,
+ ServiceInputTypes,
+ ServiceOutputTypes,
+} from '@aws-sdk/client-dynamodb';
+import { AwsStub, mockClient } from 'aws-sdk-client-mock';
+import { getClient } from '@/utils/dynamodb/getClient';
+import { update } from './update';
+
+describe('technology.update()', () => {
+ let subject: Awaited>;
+ let ddbMock: AwsStub;
+
+ beforeAll(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('when no existing record', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolvesOnce({
+ Item: undefined,
+ });
+ subject = await update('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff', {
+ displayName: 'Jest v29',
+ });
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+ });
+
+ describe('when record updated successfully', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolvesOnce({
+ Item: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ ddbMock.on(PutItemCommand).resolves({
+ Attributes: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest v29' },
+ },
+ });
+ subject = await update('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff', {
+ displayName: 'Jest v29',
+ });
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ });
+
+ it('returns the updated record', () => {
+ expect(subject).toEqual({
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ displayName: 'Jest v29',
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ websiteUrl: 'https://jestjs.io/',
+ });
+ });
+ });
+
+ describe('when record update fails', () => {
+ beforeAll(async () => {
+ ddbMock = mockClient(getClient());
+ ddbMock.on(GetItemCommand).resolvesOnce({
+ Item: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ ddbMock.on(PutItemCommand).rejects('mock error');
+ subject = await update('87af19b1-aa0d-4178-a30c-2fa8cd1f2cff', {
+ displayName: 'Jest v29',
+ });
+ });
+
+ afterAll(() => {
+ ddbMock.restore();
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/models/technology/update.ts b/starters/serverless-framework-sqs-dynamodb/src/models/technology/update.ts
new file mode 100644
index 000000000..b90647d60
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/models/technology/update.ts
@@ -0,0 +1,25 @@
+import { TechnologyUpdate } from '@/types/technology';
+import { addToCache } from '@/utils/cache/addToCache';
+import { putItem } from '@/utils/dynamodb/putItem';
+import { get } from './get';
+import { getCacheKey } from './getCacheKey';
+
+export const update = async (id: string, payload: TechnologyUpdate) => {
+ const existingTechnology = await get(id);
+ if (!existingTechnology) {
+ return null;
+ }
+
+ const updatedTechnology = {
+ id,
+ ...existingTechnology,
+ ...payload,
+ };
+
+ const response = await putItem(process.env.TECHNOLOGIES_TABLE, updatedTechnology);
+ if (!response) {
+ return null;
+ }
+ await addToCache(getCacheKey(id), updatedTechnology);
+ return updatedTechnology;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/types/api-types.d.ts b/starters/serverless-framework-sqs-dynamodb/src/types/api-types.d.ts
new file mode 100644
index 000000000..ebcc4fe92
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/types/api-types.d.ts
@@ -0,0 +1,40 @@
+// required to be manually generated because of https://github.com/completecoding/serverless-auto-swagger/issues/45
+
+export interface Technology {
+ id: string;
+ displayName: string;
+ description: string;
+ websiteUrl: string;
+}
+
+export type Technologies = Technology[];
+
+export interface TechnologyCreateBody {
+ displayName: string;
+ description: string;
+ websiteUrl: string;
+}
+
+export interface TechnologyCreateFormError {
+ formErrors: string[];
+ fieldErrors: {
+ displayName?: string[] | undefined;
+ description?: string[] | undefined;
+ websiteUrl?: string[] | undefined;
+ };
+}
+
+export interface TechnologyUpdateBody {
+ displayName?: string | undefined;
+ description?: string | undefined;
+ websiteUrl?: string | undefined;
+}
+
+export interface TechnologyUpdateFormError {
+ formErrors: string[];
+ fieldErrors: {
+ displayName?: string[] | undefined;
+ description?: string[] | undefined;
+ websiteUrl?: string[] | undefined;
+ };
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/types/environment.d.ts b/starters/serverless-framework-sqs-dynamodb/src/types/environment.d.ts
new file mode 100644
index 000000000..ecffbdf71
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/types/environment.d.ts
@@ -0,0 +1,14 @@
+declare global {
+ namespace NodeJS {
+ interface ProcessEnv {
+ REGION: string;
+ SLS_STAGE: string;
+
+ DEFAULT_CACHE_TIME: string;
+ REDIS_CACHE_URL: string;
+ TECHNOLOGIES_TABLE: string;
+ }
+ }
+}
+
+export {};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/types/technology.ts b/starters/serverless-framework-sqs-dynamodb/src/types/technology.ts
new file mode 100644
index 000000000..61322b184
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/types/technology.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+export type Technology = {
+ id: string;
+ displayName: string;
+ description: string;
+ websiteUrl: string;
+};
+
+export const TechnologySchema = z.object({
+ id: z.string().uuid(),
+ displayName: z.string(),
+ description: z.string().min(10),
+ websiteUrl: z.string().url(),
+});
+
+export const TechnologyCreateSchema = z.object({
+ displayName: z.string(),
+ description: z.string().min(10),
+ websiteUrl: z.string().url(),
+});
+
+export type TechnologyCreate = z.infer;
+
+export const TechnologyUpdateSchema = z.object({
+ displayName: z.string().optional(),
+ description: z.string().min(10).optional(),
+ websiteUrl: z.string().url().optional(),
+});
+
+export type TechnologyUpdate = z.infer;
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/addToCache.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/addToCache.test.ts
new file mode 100644
index 000000000..c8aa97751
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/addToCache.test.ts
@@ -0,0 +1,39 @@
+import Redis from 'ioredis';
+import { getClient as getRedisClient } from '@/utils/redis/getClient';
+import { DEFAULT_CACHE_TIME } from './constants';
+import { addToCache } from './addToCache';
+
+describe('cache.addToCache()', () => {
+ let redisClient: Redis;
+
+ beforeAll(async () => {
+ jest.resetModules();
+ jest.useFakeTimers();
+ redisClient = await getRedisClient('cache', process.env.REDIS_CACHE_URL);
+ jest.spyOn(redisClient, 'set');
+ await addToCache('test-add', {
+ a: 1,
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllTimers();
+ });
+
+ it('sets a value to the cache', () => {
+ expect(redisClient.set).toHaveBeenCalledWith(
+ 'test-add',
+ JSON.stringify({
+ value: {
+ a: 1,
+ },
+ metadata: {
+ createdTime: Date.now(),
+ ttl: DEFAULT_CACHE_TIME,
+ },
+ }),
+ 'PX',
+ DEFAULT_CACHE_TIME
+ );
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/addToCache.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/addToCache.ts
new file mode 100644
index 000000000..ae4678083
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/addToCache.ts
@@ -0,0 +1,13 @@
+import { DEFAULT_CACHE_TIME } from './constants';
+import { getClient } from './getClient';
+
+export async function addToCache(key: string, value: unknown, ttl: number = DEFAULT_CACHE_TIME) {
+ const cache = await getClient();
+ await cache.set(key, {
+ value,
+ metadata: {
+ createdTime: Date.now(),
+ ttl,
+ },
+ });
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/constants.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/constants.ts
new file mode 100644
index 000000000..868d5ab0f
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/constants.ts
@@ -0,0 +1 @@
+export const DEFAULT_CACHE_TIME = parseInt(process.env.DEFAULT_CACHE_TIME, 10);
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/getClient.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/getClient.test.ts
new file mode 100644
index 000000000..d606dc434
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/getClient.test.ts
@@ -0,0 +1,32 @@
+import { getClient } from './getClient';
+
+describe('cache.getClient()', () => {
+ let subject: Awaited>;
+
+ describe('when called once', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ subject = await getClient();
+ });
+
+ it('returns a cache instance', () => {
+ expect(subject).toHaveProperty('set');
+ expect(subject).toHaveProperty('get');
+ expect(subject).toHaveProperty('delete');
+ });
+ });
+
+ describe('when called twice', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ await getClient();
+ subject = await getClient();
+ });
+
+ it('returns a cache instance', () => {
+ expect(subject).toHaveProperty('set');
+ expect(subject).toHaveProperty('get');
+ expect(subject).toHaveProperty('delete');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/getClient.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/getClient.ts
new file mode 100644
index 000000000..10699c143
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/getClient.ts
@@ -0,0 +1,33 @@
+import { Cache, totalTtl } from 'cachified';
+import { getClient as getRedisClient } from '@/utils/redis/getClient';
+
+let cachedClient: Cache;
+export const getClient = async () => {
+ if (cachedClient) {
+ return cachedClient;
+ }
+
+ const redisClient = await getRedisClient('cache', process.env.REDIS_CACHE_URL);
+ cachedClient = {
+ name: redisClient.options?.name || 'Redis',
+ async get(key) {
+ const value = await redisClient.get(key);
+ if (!value) {
+ return null;
+ }
+ return JSON.parse(value);
+ },
+ async set(key, value) {
+ const ttl = totalTtl(value?.metadata);
+ if (ttl > 0 && ttl < Infinity) {
+ return redisClient.set(key, JSON.stringify(value), 'PX', ttl);
+ }
+ return redisClient.set(key, JSON.stringify(value));
+ },
+ async delete(key) {
+ return redisClient.del(key);
+ },
+ };
+
+ return cachedClient;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/removeFromCache.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/removeFromCache.test.ts
new file mode 100644
index 000000000..c732d5a0a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/removeFromCache.test.ts
@@ -0,0 +1,18 @@
+import Redis from 'ioredis';
+import { getClient as getRedisClient } from '@/utils/redis/getClient';
+import { removeFromCache } from './removeFromCache';
+
+describe('cache.removeFromCache()', () => {
+ let redisClient: Redis;
+
+ beforeAll(async () => {
+ jest.resetModules();
+ redisClient = await getRedisClient('cache', process.env.REDIS_CACHE_URL);
+ jest.spyOn(redisClient, 'del');
+ await removeFromCache('test-del');
+ });
+
+ it('deletes a value', () => {
+ expect(redisClient.del).toHaveBeenCalledWith('test-del');
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/removeFromCache.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/removeFromCache.ts
new file mode 100644
index 000000000..6220a3921
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/removeFromCache.ts
@@ -0,0 +1,6 @@
+import { getClient } from './getClient';
+
+export async function removeFromCache(key: string) {
+ const cache = await getClient();
+ await cache.delete(key);
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/useCache.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/useCache.test.ts
new file mode 100644
index 000000000..c57080053
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/useCache.test.ts
@@ -0,0 +1,83 @@
+import { useCache } from './useCache';
+
+describe('cache.useCache()', () => {
+ let subject: Awaited>;
+
+ describe('when fails checkValue', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ subject = await useCache({
+ key: 'test-with-ttl',
+ getFreshValue() {
+ return 'test-value';
+ },
+ checkValue(value: unknown) {
+ if (typeof value === 'string') {
+ return value.startsWith('tests-');
+ }
+ return true;
+ },
+ });
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+ });
+
+ describe('when using default ttl', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ subject = await useCache({
+ key: 'test-with-ttl',
+ getFreshValue() {
+ return 'test-value';
+ },
+ });
+ });
+
+ it('returns a fresh fetched value', () => {
+ expect(subject).toEqual('test-value');
+ });
+ });
+
+ describe('when no ttl', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ subject = await useCache({
+ key: 'test-no-ttl',
+ getFreshValue() {
+ return 'test-value';
+ },
+ ttl: 0,
+ });
+ });
+
+ it('returns a fresh fetched value', () => {
+ expect(subject).toEqual('test-value');
+ });
+ });
+
+ describe('when called twice', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ await useCache({
+ key: 'test-double',
+ getFreshValue() {
+ return 'test-value';
+ },
+ });
+ subject = await useCache({
+ key: 'test-double',
+ getFreshValue() {
+ return 'test-value';
+ },
+ });
+ });
+
+ it('returns a fresh fetched value', () => {
+ expect(subject).toEqual('test-value');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/cache/useCache.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/useCache.ts
new file mode 100644
index 000000000..bee9526af
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/cache/useCache.ts
@@ -0,0 +1,33 @@
+import { cachified, CachifiedOptions } from 'cachified';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { DEFAULT_CACHE_TIME } from './constants';
+import { getClient } from './getClient';
+import { removeFromCache } from './removeFromCache';
+
+export async function useCache({
+ key,
+ getFreshValue,
+ ttl = DEFAULT_CACHE_TIME,
+ checkValue = undefined,
+}: Omit, 'cache'>) {
+ try {
+ const cache = await getClient();
+ const cachifiedOptions: CachifiedOptions = {
+ cache,
+ key,
+ getFreshValue,
+ ttl,
+ };
+
+ if (checkValue) {
+ cachifiedOptions.checkValue = checkValue;
+ }
+
+ const cacheValue = await cachified(cachifiedOptions);
+ return cacheValue;
+ } catch (err) {
+ await removeFromCache(key);
+ console.error(getErrorMessage(err));
+ return null;
+ }
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/deleteItem.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/deleteItem.test.ts
new file mode 100644
index 000000000..93cad37ba
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/deleteItem.test.ts
@@ -0,0 +1,76 @@
+import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from './getClient';
+import { deleteItem } from './deleteItem';
+
+describe('dynamodb.deleteItem()', () => {
+ let subject: Awaited>;
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when item exists', () => {
+ beforeAll(async () => {
+ ddbMock.on(DeleteItemCommand).resolves({
+ Attributes: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ subject = await deleteItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ });
+ });
+
+ it('returns the old unmarshalled item', () => {
+ expect(subject).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+ });
+
+ describe('when item does not exist', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ ddbMock.on(DeleteItemCommand).resolves({
+ Attributes: undefined,
+ });
+ subject = await deleteItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ });
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ ddbMock.on(DeleteItemCommand).rejects('mock error');
+ subject = await deleteItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ });
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+
+ it('logs the error', () => {
+ expect(console.error).toHaveBeenCalledWith('dynamodb.deleteItem Error - mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/deleteItem.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/deleteItem.ts
new file mode 100644
index 000000000..92eee28d8
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/deleteItem.ts
@@ -0,0 +1,26 @@
+import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';
+import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient } from './getClient';
+
+export const deleteItem = async (tableName: string, key: Record) => {
+ const command = new DeleteItemCommand({
+ TableName: tableName,
+ Key: marshall(key),
+ ReturnValues: 'ALL_OLD',
+ });
+ const client = getClient();
+
+ try {
+ const response = await client.send(command);
+
+ if (!response || !response.Attributes) {
+ console.warn('dynamodb.deleteItem Warning - ${response}');
+ return null;
+ }
+ return unmarshall(response.Attributes);
+ } catch (err) {
+ console.error(`dynamodb.deleteItem Error - ${getErrorMessage(err)}`);
+ return null;
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getClient.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getClient.test.ts
new file mode 100644
index 000000000..ccfbfca59
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getClient.test.ts
@@ -0,0 +1,85 @@
+import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
+
+describe('dynamodb.getClient()', () => {
+ const OLD_ENV = process.env;
+ let subject: DynamoDBClient;
+ let client: DynamoDBClient;
+
+ describe('when IS_OFFLINE is true', () => {
+ beforeAll(() => {
+ jest.resetModules();
+ client = require('@aws-sdk/client-dynamodb').DynamoDBClient;
+ const { getClient } = require('./getClient');
+ process.env = {
+ ...OLD_ENV,
+ IS_OFFLINE: 'true',
+ };
+ subject = getClient();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('returns an DynamoDBClient', () => {
+ expect(subject).toEqual(expect.any(client));
+ });
+
+ it('sets the endpoint to localhost', async () => {
+ if (!subject.config.endpoint) {
+ fail('client misconfigured');
+ }
+ const { hostname } = await subject.config.endpoint();
+ expect(hostname).toMatch(/localhost/);
+ });
+ });
+
+ describe('when IS_OFFLINE is false', () => {
+ beforeAll(() => {
+ jest.resetModules();
+ client = require('@aws-sdk/client-dynamodb').DynamoDBClient;
+ const { getClient } = require('./getClient');
+ process.env = {
+ ...OLD_ENV,
+ IS_OFFLINE: 'false',
+ };
+ subject = getClient();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('returns an DynamoDBClient', () => {
+ expect(subject).toEqual(expect.any(client));
+ });
+
+ it('uses the default AWS Lambda endpoint', async () => {
+ expect(subject.config.endpoint).toBeUndefined();
+ });
+ });
+
+ describe('when called twice', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+
+ jest.mock('@aws-sdk/client-dynamodb');
+ client = require('@aws-sdk/client-dynamodb').DynamoDBClient;
+ const { getClient } = require('./getClient');
+ process.env = {
+ ...OLD_ENV,
+ IS_OFFLINE: 'true',
+ };
+ getClient();
+ subject = getClient();
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('runs the constructor once', () => {
+ expect(client).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getClient.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getClient.ts
new file mode 100644
index 000000000..af0d3c77d
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getClient.ts
@@ -0,0 +1,28 @@
+import { DynamoDBClient, DynamoDBClientConfig } from '@aws-sdk/client-dynamodb';
+import { isOffline } from '@/utils/isOffline/isOffline';
+
+let cachedClient: DynamoDBClient;
+
+export const getClient = (): DynamoDBClient => {
+ if (cachedClient) {
+ return cachedClient;
+ }
+
+ const { REGION } = process.env;
+ const config: DynamoDBClientConfig = {
+ apiVersion: '2031',
+ region: REGION,
+ };
+
+ if (isOffline()) {
+ config.endpoint = 'http://localhost:8000';
+ // needs to be set if AWS credentials aren't configured
+ config.credentials = {
+ accessKeyId: 'DEFAULT_ACCESS_KEY',
+ secretAccessKey: 'DEFAULT_SECRET',
+ };
+ }
+
+ cachedClient = new DynamoDBClient(config);
+ return cachedClient;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getItem.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getItem.test.ts
new file mode 100644
index 000000000..b3f79507e
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getItem.test.ts
@@ -0,0 +1,75 @@
+import { GetItemCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from './getClient';
+import { getItem } from './getItem';
+
+describe('dynamodb.getItem()', () => {
+ let subject: Awaited>;
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when item is found', () => {
+ beforeAll(async () => {
+ ddbMock.on(GetItemCommand).resolves({
+ Item: {
+ description: {
+ S: 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ },
+ id: { S: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff' },
+ websiteUrl: { S: 'https://jestjs.io/' },
+ displayName: { S: 'Jest' },
+ },
+ });
+ subject = await getItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ });
+ });
+
+ it('returns the unmarshalled item', () => {
+ expect(subject).toEqual({
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+ });
+
+ describe('when item is not found', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ ddbMock.on(GetItemCommand).resolves({
+ Item: undefined,
+ });
+ subject = await getItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ });
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ ddbMock.on(GetItemCommand).rejects('mock error');
+ subject = await getItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ });
+ });
+
+ it('returns null', () => {
+ expect(subject).toBeNull();
+ });
+
+ it('logs the error', () => {
+ expect(console.error).toHaveBeenCalledWith('dynamodb.getItem Error - mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getItem.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getItem.ts
new file mode 100644
index 000000000..c9f489e94
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/getItem.ts
@@ -0,0 +1,28 @@
+import { GetItemCommand, GetItemCommandInput } from '@aws-sdk/client-dynamodb';
+import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient } from './getClient';
+
+export const getItem = async (
+ tableName: string,
+ key: Record,
+ projection: GetItemCommandInput['ProjectionExpression'] = undefined
+): Promise | null> => {
+ const command = new GetItemCommand({
+ TableName: tableName,
+ Key: marshall(key),
+ ProjectionExpression: projection,
+ });
+ const client = getClient();
+
+ try {
+ const response = await client.send(command);
+ if (!response || !response.Item) {
+ return null;
+ }
+ return unmarshall(response.Item);
+ } catch (err) {
+ console.error(`dynamodb.getItem Error - ${getErrorMessage(err)}`);
+ return null;
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/listTables.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/listTables.test.ts
new file mode 100644
index 000000000..7f32f891e
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/listTables.test.ts
@@ -0,0 +1,56 @@
+import { ListTablesCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from './getClient';
+import { listTables } from './listTables';
+
+describe('dynamodb.listTables()', () => {
+ let subject: Awaited> | ReturnType;
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when items are found', () => {
+ beforeAll(async () => {
+ ddbMock.on(ListTablesCommand).resolves({
+ TableNames: ['Table1', 'Table2'],
+ });
+ subject = await listTables();
+ });
+
+ it('returns the table names', () => {
+ expect(subject).toEqual(['Table1', 'Table2']);
+ });
+ });
+
+ describe('when items are not found', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ ddbMock.on(ListTablesCommand).resolves({
+ TableNames: undefined,
+ });
+ subject = listTables();
+ });
+
+ it('throws error', async () => {
+ await expect(subject).rejects.toThrow();
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ ddbMock.on(ListTablesCommand).rejects('mock error');
+ subject = listTables();
+ });
+
+ it('throws error', async () => {
+ await expect(subject).rejects.toThrow();
+ });
+
+ it('logs the error', () => {
+ expect(console.error).toHaveBeenCalledWith('dynamodb.listTables Error - mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/listTables.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/listTables.ts
new file mode 100644
index 000000000..bc9b8221c
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/listTables.ts
@@ -0,0 +1,19 @@
+import { ListTablesCommand } from '@aws-sdk/client-dynamodb';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient } from './getClient';
+
+export const listTables = async () => {
+ const command = new ListTablesCommand({});
+ const client = getClient();
+
+ try {
+ const response = await client.send(command);
+ if (!response || !response.TableNames) {
+ throw new Error('Unable to get tables');
+ }
+ return response.TableNames;
+ } catch (err) {
+ console.error(`dynamodb.listTables Error - ${getErrorMessage(err)}`);
+ throw err;
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/putItem.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/putItem.test.ts
new file mode 100644
index 000000000..8e0d86c7c
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/putItem.test.ts
@@ -0,0 +1,52 @@
+import { PutItemCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from './getClient';
+import { putItem } from './putItem';
+
+describe('dynamodb.putItem()', () => {
+ let subject: Awaited>;
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when putting an item', () => {
+ beforeAll(async () => {
+ ddbMock.on(PutItemCommand).resolves({
+ Attributes: undefined,
+ });
+ subject = await putItem('technology-test', {
+ description:
+ 'Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works with projects using: Babel, TypeScript, Node, React, Angular, Vue and more!',
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ websiteUrl: 'https://jestjs.io/',
+ displayName: 'Jest',
+ });
+ });
+
+ it('returns true', () => {
+ expect(subject).toEqual(true);
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ ddbMock.on(PutItemCommand).rejects('mock error');
+ subject = await putItem('technology-test', {
+ id: '87af19b1-aa0d-4178-a30c-2fa8cd1f2cff',
+ displayName: 'Jest Test',
+ });
+ });
+
+ it('returns false', () => {
+ expect(subject).toEqual(false);
+ });
+
+ it('logs the error', () => {
+ expect(console.error).toHaveBeenCalledWith('dynamodb.putItem Error - mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/putItem.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/putItem.ts
new file mode 100644
index 000000000..ccfa4887b
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/putItem.ts
@@ -0,0 +1,20 @@
+import { PutItemCommand } from '@aws-sdk/client-dynamodb';
+import { marshall } from '@aws-sdk/util-dynamodb';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient } from './getClient';
+
+export const putItem = async (tableName: string, item: Record) => {
+ const command = new PutItemCommand({
+ TableName: tableName,
+ Item: marshall(item),
+ });
+ const client = getClient();
+
+ try {
+ await client.send(command);
+ return true;
+ } catch (err) {
+ console.error(`dynamodb.putItem Error - ${getErrorMessage(err)}`);
+ return false;
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/scan.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/scan.test.ts
new file mode 100644
index 000000000..af8f79d96
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/scan.test.ts
@@ -0,0 +1,102 @@
+import { ScanCommand } from '@aws-sdk/client-dynamodb';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from './getClient';
+import { scan } from './scan';
+
+describe('dynamodb.scan()', () => {
+ let subject: Awaited>;
+ const ddbMock = mockClient(getClient());
+
+ afterAll(() => {
+ ddbMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when items are found', () => {
+ beforeAll(async () => {
+ ddbMock.on(ScanCommand).resolves({
+ Items: [
+ {
+ description: {
+ S: 'Fully managed message queuing for microservices, distributed systems, and serverless applications',
+ },
+ id: { S: 'ca9085fa-45a6-4d56-b6ee-8d6c59bebbfa' },
+ websiteUrl: { S: 'https://aws.amazon.com/sqs/' },
+ displayName: { S: 'AWS SQS' },
+ },
+ {
+ description: {
+ S: 'Fast, flexible NoSQL database service for single-digit millisecond performance at any scale',
+ },
+ id: { S: 'dfb3378f-a991-4e45-8e23-59e27768f96f' },
+ websiteUrl: { S: 'https://aws.amazon.com/dynamodb/' },
+ displayName: { S: 'AWS DynamoDB' },
+ },
+ {
+ description: {
+ S: 'All-in-one development solution for auto-scaling apps on AWS Lambda',
+ },
+ id: { S: '629202fd-00bc-46f2-9f67-3ddeb149b931' },
+ websiteUrl: { S: 'https://www.serverless.com/' },
+ displayName: { S: 'Serverless Framework' },
+ },
+ ],
+ });
+ subject = await scan('technology-test');
+ });
+
+ it('returns the unmarshalled items', () => {
+ expect(subject).toEqual([
+ {
+ description:
+ 'Fully managed message queuing for microservices, distributed systems, and serverless applications',
+ id: 'ca9085fa-45a6-4d56-b6ee-8d6c59bebbfa',
+ websiteUrl: 'https://aws.amazon.com/sqs/',
+ displayName: 'AWS SQS',
+ },
+ {
+ description:
+ 'Fast, flexible NoSQL database service for single-digit millisecond performance at any scale',
+ id: 'dfb3378f-a991-4e45-8e23-59e27768f96f',
+ websiteUrl: 'https://aws.amazon.com/dynamodb/',
+ displayName: 'AWS DynamoDB',
+ },
+ {
+ description: 'All-in-one development solution for auto-scaling apps on AWS Lambda',
+ id: '629202fd-00bc-46f2-9f67-3ddeb149b931',
+ websiteUrl: 'https://www.serverless.com/',
+ displayName: 'Serverless Framework',
+ },
+ ]);
+ });
+ });
+
+ describe('when items are not found', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ ddbMock.on(ScanCommand).resolves({
+ Items: undefined,
+ });
+ subject = await scan('technology-test');
+ });
+
+ it('returns empty array', () => {
+ expect(subject).toEqual([]);
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ ddbMock.on(ScanCommand).rejects('mock error');
+ subject = await scan('technology-test');
+ });
+
+ it('returns null', () => {
+ expect(subject).toEqual([]);
+ });
+
+ it('logs the error', () => {
+ expect(console.error).toHaveBeenCalledWith('dynamodb.scan Error - mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/scan.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/scan.ts
new file mode 100644
index 000000000..08f12ddf5
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/dynamodb/scan.ts
@@ -0,0 +1,22 @@
+import { ScanCommand } from '@aws-sdk/client-dynamodb';
+import { unmarshall } from '@aws-sdk/util-dynamodb';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient } from './getClient';
+
+export const scan = async (tableName: string) => {
+ const command = new ScanCommand({
+ TableName: tableName,
+ });
+ const client = getClient();
+
+ try {
+ const response = await client.send(command);
+ if (!response || !response.Items) {
+ return [];
+ }
+ return response.Items.map((item) => unmarshall(item));
+ } catch (err) {
+ console.error(`dynamodb.scan Error - ${getErrorMessage(err)}`);
+ return [];
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/error/getErrorMessage.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/error/getErrorMessage.test.ts
new file mode 100644
index 000000000..6935bf3e1
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/error/getErrorMessage.test.ts
@@ -0,0 +1,29 @@
+import { getErrorMessage } from './getErrorMessage';
+
+type CircularType = { obj?: unknown };
+const circularExample: CircularType = {};
+circularExample.obj = circularExample;
+
+describe('getErrorMessage()', () => {
+ describe.each([
+ {
+ label: 'error object with message',
+ input: new Error('generic error message'),
+ expected: 'generic error message',
+ },
+ {
+ label: 'generic thrown error',
+ input: 'non-error error message',
+ expected: '"non-error error message"',
+ },
+ {
+ label: 'generic thrown error with parsing issue',
+ input: circularExample,
+ expected: '[object Object]',
+ },
+ ])('return $label', ({ input, expected }) => {
+ test(`return ${expected}`, () => {
+ expect(getErrorMessage(input)).toEqual(expected);
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/error/getErrorMessage.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/error/getErrorMessage.ts
new file mode 100644
index 000000000..b3e238e27
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/error/getErrorMessage.ts
@@ -0,0 +1,29 @@
+// sourced from https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
+type ErrorWithMessage = {
+ message: string;
+};
+
+function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error &&
+ typeof (error as Record).message === 'string'
+ );
+}
+
+function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
+ if (isErrorWithMessage(maybeError)) return maybeError;
+
+ try {
+ return new Error(JSON.stringify(maybeError));
+ } catch {
+ // fallback in case there's an error stringifying the maybeError
+ // like with circular references for example.
+ return new Error(String(maybeError));
+ }
+}
+
+export function getErrorMessage(error: unknown) {
+ return toErrorWithMessage(error).message;
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/isOffline/isOffline.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/isOffline/isOffline.test.ts
new file mode 100644
index 000000000..70071d67a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/isOffline/isOffline.test.ts
@@ -0,0 +1,26 @@
+import { isOffline } from './isOffline';
+
+describe('isOffline()', () => {
+ const env = process.env;
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...env };
+ });
+
+ afterEach(() => {
+ process.env = env;
+ });
+
+ // run each of the 3 possible scenarios
+ const cases = [
+ ['true', true],
+ ['false', false],
+ ['undefined', false],
+ ];
+ test.each(cases)('when process.env.IS_OFFLINE set to %p, returns %p', (a, b) => {
+ // set env to test case
+ process.env.IS_OFFLINE = String(a);
+ expect(isOffline()).toEqual(b);
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/isOffline/isOffline.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/isOffline/isOffline.ts
new file mode 100644
index 000000000..c0ba0549f
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/isOffline/isOffline.ts
@@ -0,0 +1,7 @@
+/**
+ * Utility function for checking if functions are being run locally via serverless offline
+ * or if they're running on infrastructure. Helpful for detecting which connection string to use.
+ *
+ * @returns boolean are we running locally or on infra?
+ */
+export const isOffline = (): boolean => process.env.IS_OFFLINE === 'true';
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/redis/getClient.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/redis/getClient.test.ts
new file mode 100644
index 000000000..1a33a55ba
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/redis/getClient.test.ts
@@ -0,0 +1,29 @@
+import Redis from 'ioredis';
+import { getClient } from './getClient';
+
+describe('redis.getClient()', () => {
+ let subject: Redis;
+
+ describe('when called once', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ subject = await getClient('test', 'fakeurl');
+ });
+
+ it('returns a Redis instance', () => {
+ expect(subject).toEqual(expect.any(Redis));
+ });
+ });
+
+ describe('when called twice', () => {
+ beforeAll(async () => {
+ jest.resetModules();
+ await getClient('test', 'fakeurl');
+ subject = await getClient('test', 'fakeurl');
+ });
+
+ it('returns a Redis client', () => {
+ expect(subject).toEqual(expect.any(Redis));
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/redis/getClient.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/redis/getClient.ts
new file mode 100644
index 000000000..1312fb63b
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/redis/getClient.ts
@@ -0,0 +1,13 @@
+import Redis from 'ioredis';
+
+const cachedClients: Record = {};
+
+export const getClient = async (type: string, url: string): Promise => {
+ if (cachedClients[type]) {
+ return cachedClients[type];
+ }
+
+ const newClient = new Redis(`${url}/0?allowUsernameInURI=true`);
+ cachedClients[type] = newClient;
+ return newClient;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/responseHelper/responseHelper.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/responseHelper/responseHelper.test.ts
new file mode 100644
index 000000000..8e82b67c1
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/responseHelper/responseHelper.test.ts
@@ -0,0 +1,35 @@
+import { responseHelper } from './responseHelper';
+
+describe('responseHelper()', () => {
+ let subject: ReturnType;
+
+ describe('when resp is string', () => {
+ beforeAll(() => {
+ subject = responseHelper(200, 'some string');
+ });
+
+ it('returns a response object', () => {
+ expect(subject).toHaveProperty('statusCode');
+ expect(subject).toHaveProperty('body');
+ });
+
+ it('sets body to the provided string', () => {
+ expect(subject.body).toEqual('some string');
+ });
+ });
+
+ describe('when resp is not a string', () => {
+ beforeAll(() => {
+ subject = responseHelper(200, null);
+ });
+
+ it('returns a response object', () => {
+ expect(subject).toHaveProperty('statusCode');
+ expect(subject).toHaveProperty('body');
+ });
+
+ it('sets body to the JSON stringified value', () => {
+ expect(subject.body).toEqual('null');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/responseHelper/responseHelper.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/responseHelper/responseHelper.ts
new file mode 100644
index 000000000..72de1651f
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/responseHelper/responseHelper.ts
@@ -0,0 +1,6 @@
+export function responseHelper(statusCode: number, resp: unknown) {
+ return {
+ statusCode,
+ body: typeof resp === 'string' ? resp : JSON.stringify(resp),
+ };
+}
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getClient.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getClient.ts
new file mode 100644
index 000000000..8762a5ca2
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getClient.ts
@@ -0,0 +1,20 @@
+import { SQSClient, SQSClientConfig } from '@aws-sdk/client-sqs';
+import { isOffline } from '@/utils/isOffline/isOffline';
+
+export type QueueName = 'ExampleQueue';
+
+let cachedClient: SQSClient | null = null;
+
+export const getClient = (): SQSClient => {
+ if (cachedClient) {
+ return cachedClient;
+ }
+
+ const config: SQSClientConfig = {};
+ if (isOffline()) {
+ config.endpoint = 'http://localhost:9324';
+ }
+
+ cachedClient = new SQSClient(config);
+ return cachedClient;
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getQueueUrl.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getQueueUrl.test.ts
new file mode 100644
index 000000000..bf752bcdc
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getQueueUrl.test.ts
@@ -0,0 +1,57 @@
+import { mockClient } from 'aws-sdk-client-mock';
+import { SQSClient, GetQueueUrlCommand } from '@aws-sdk/client-sqs';
+import { getQueueUrl } from './getQueueUrl';
+
+describe('getQueueUrl', () => {
+ let subject: ReturnType | Awaited>;
+ let sqsMock: ReturnType;
+
+ beforeAll(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ sqsMock = mockClient(SQSClient);
+ });
+
+ afterAll(() => {
+ sqsMock.restore();
+ jest.restoreAllMocks();
+ });
+
+ describe('when valid params are provided', () => {
+ describe('when queue is found', () => {
+ beforeAll(async () => {
+ sqsMock.on(GetQueueUrlCommand).resolves({
+ QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/ExampleQueue',
+ });
+ subject = await getQueueUrl('ExampleQueue');
+ });
+
+ it('returns QueueUrl', () => {
+ expect(subject).toEqual('https://sqs.us-east-1.amazonaws.com/123456789012/ExampleQueue');
+ });
+ });
+
+ describe('when queue is not found', () => {
+ beforeAll(() => {
+ sqsMock.on(GetQueueUrlCommand).resolves({
+ QueueUrl: undefined,
+ });
+ subject = getQueueUrl('ExampleQueue');
+ });
+
+ it('throws an error', async () => {
+ await expect(subject).rejects.toThrow('Queue not found');
+ });
+ });
+ });
+
+ describe('when invalid params are provided', () => {
+ beforeAll(() => {
+ sqsMock.on(GetQueueUrlCommand).rejects('mocked rejection');
+ subject = getQueueUrl('ExampleQueue');
+ });
+
+ it('throws an exception with the error message', async () => {
+ await expect(subject).rejects.toThrow('mocked rejection');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getQueueUrl.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getQueueUrl.ts
new file mode 100644
index 000000000..a9465e800
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/getQueueUrl.ts
@@ -0,0 +1,21 @@
+import { GetQueueUrlCommand } from '@aws-sdk/client-sqs';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient, QueueName } from './getClient';
+
+export const getQueueUrl = async (queue: QueueName): Promise => {
+ const command = new GetQueueUrlCommand({
+ QueueName: queue,
+ });
+
+ try {
+ const { QueueUrl } = await getClient().send(command);
+ if (!QueueUrl) {
+ throw new Error('Queue not found');
+ }
+ return QueueUrl;
+ } catch (error) {
+ const message = getErrorMessage(error);
+ console.error(message);
+ throw new Error(message);
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/listQueues.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/listQueues.test.ts
new file mode 100644
index 000000000..d11ec76c6
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/listQueues.test.ts
@@ -0,0 +1,56 @@
+import { ListQueuesCommand } from '@aws-sdk/client-sqs';
+import { mockClient } from 'aws-sdk-client-mock';
+import { getClient } from './getClient';
+import { listQueues } from './listQueues';
+
+describe('dynamodb.listQueues()', () => {
+ let subject: Awaited> | ReturnType;
+ const sqsMock = mockClient(getClient());
+
+ afterAll(() => {
+ sqsMock.restore();
+ jest.resetAllMocks();
+ });
+
+ describe('when items are found', () => {
+ beforeAll(async () => {
+ sqsMock.on(ListQueuesCommand).resolves({
+ QueueUrls: ['http://localhost:9324/000000000000/ExampleQueue'],
+ });
+ subject = await listQueues();
+ });
+
+ it('returns the queue names', () => {
+ expect(subject).toEqual(['http://localhost:9324/000000000000/ExampleQueue']);
+ });
+ });
+
+ describe('when items are not found', () => {
+ beforeAll(async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ sqsMock.on(ListQueuesCommand).resolves({
+ QueueUrls: undefined,
+ });
+ subject = listQueues();
+ });
+
+ it('throws error', async () => {
+ await expect(subject).rejects.toThrow();
+ });
+ });
+
+ describe('when error occurs', () => {
+ beforeAll(async () => {
+ sqsMock.on(ListQueuesCommand).rejects('mock error');
+ subject = listQueues();
+ });
+
+ it('throws error', async () => {
+ await expect(subject).rejects.toThrow();
+ });
+
+ it('logs the error', () => {
+ expect(console.error).toHaveBeenCalledWith('sqs.listQueues Error - mock error');
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/listQueues.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/listQueues.ts
new file mode 100644
index 000000000..591d7f3d8
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/listQueues.ts
@@ -0,0 +1,19 @@
+import { ListQueuesCommand } from '@aws-sdk/client-sqs';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient } from './getClient';
+
+export const listQueues = async () => {
+ const command = new ListQueuesCommand({});
+ const client = getClient();
+
+ try {
+ const response = await client.send(command);
+ if (!response || !response.QueueUrls) {
+ throw new Error('Unable to get queues');
+ }
+ return response.QueueUrls;
+ } catch (err) {
+ console.error(`sqs.listQueues Error - ${getErrorMessage(err)}`);
+ throw err;
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/sendMessage.test.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/sendMessage.test.ts
new file mode 100644
index 000000000..0ac68e02a
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/sendMessage.test.ts
@@ -0,0 +1,58 @@
+import { mockClient } from 'aws-sdk-client-mock';
+import { SQSClient, SendMessageCommand, GetQueueUrlCommand } from '@aws-sdk/client-sqs';
+import { sendMessage } from './sendMessage';
+
+describe('sendMessage', () => {
+ let subject: ReturnType | Awaited>;
+ let sqsMock: ReturnType;
+
+ beforeAll(() => {
+ sqsMock = mockClient(SQSClient);
+ sqsMock.on(GetQueueUrlCommand).resolves({
+ QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-queue',
+ });
+ });
+
+ afterAll(() => {
+ sqsMock.restore();
+ });
+
+ describe('when valid params are provided', () => {
+ let messageId: string;
+
+ beforeAll(async () => {
+ messageId = '12345678-1111-2222-3333-111122223333';
+ sqsMock.on(SendMessageCommand).resolves({
+ MessageId: messageId,
+ });
+ subject = await sendMessage('ExampleQueue', {
+ message: 'Hello World',
+ });
+ });
+
+ it('sends message to queue', () => {
+ expect(subject).toEqual({
+ success: true,
+ data: {
+ MessageId: messageId,
+ },
+ });
+ });
+ });
+
+ describe('when invalid params are provided', () => {
+ beforeAll(async () => {
+ sqsMock.on(SendMessageCommand).rejects('mocked rejection');
+ subject = await sendMessage('ExampleQueue', {
+ message: 'Hello World',
+ });
+ });
+
+ it('does not send the message to queue', () => {
+ expect(subject).toEqual({
+ success: false,
+ data: 'mocked rejection',
+ });
+ });
+ });
+});
diff --git a/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/sendMessage.ts b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/sendMessage.ts
new file mode 100644
index 000000000..a3f636afe
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/src/utils/sqs/sendMessage.ts
@@ -0,0 +1,33 @@
+import { SendMessageCommand } from '@aws-sdk/client-sqs';
+import { getErrorMessage } from '@/utils/error/getErrorMessage';
+import { getClient, QueueName } from './getClient';
+import { getQueueUrl } from './getQueueUrl';
+
+export const sendMessage = async (
+ queue: QueueName,
+ message: Record
+): Promise<{
+ success: boolean;
+ data: unknown;
+}> => {
+ const client = getClient();
+ const queueUrl = await getQueueUrl(queue);
+ const command = new SendMessageCommand({
+ QueueUrl: queueUrl,
+ MessageBody: JSON.stringify(message),
+ });
+
+ try {
+ const data = await client.send(command);
+ return {
+ success: true,
+ data,
+ };
+ } catch (error) {
+ const errMessage = getErrorMessage(error);
+ return {
+ success: false,
+ data: errMessage,
+ };
+ }
+};
diff --git a/starters/serverless-framework-sqs-dynamodb/tsconfig.json b/starters/serverless-framework-sqs-dynamodb/tsconfig.json
new file mode 100644
index 000000000..4c4c98529
--- /dev/null
+++ b/starters/serverless-framework-sqs-dynamodb/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "rootDir": ".",
+ "sourceMap": true,
+ "declaration": false,
+ "moduleResolution": "Node16",
+ "esModuleInterop": true,
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "importHelpers": false,
+ "target": "ESNext",
+ "module": "node16",
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "skipDefaultLibCheck": true,
+ "strict": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/models/*": ["src/models/*"],
+ "@/types/*": ["src/types/*"],
+ "@/utils/*": ["src/utils/*"]
+ }
+ },
+ "exclude": ["node_modules", "tmp"]
+}