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"] +}