From f239ad69f1e67ed6f9241ec32a09e8baa91e8a76 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sun, 19 Nov 2023 19:52:53 -0500 Subject: [PATCH 01/23] SLSCMN-3 config docs --- docs/services/CONFIG.md | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/services/CONFIG.md diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md new file mode 100644 index 0000000..2bb9d7d --- /dev/null +++ b/docs/services/CONFIG.md @@ -0,0 +1,79 @@ +[:point-left: Back](/README.md) + +# Configuration + +This document describes how to configure a serverless application component leveraging +shared components from the [`serverless-common`](/) package. + +## How it works + +AWS provides configuration to Lambda functions through the `process.env` object, similar to +any Node.js application. The `serverless-common` package provides the means for serverless +components to validate and access a typed configuration object. + +AWS supplies a default set of attributes to every Lambda function, e.g. `AWS_REGION`, and +you may define additional custom attributes for your functions. + +## Using the default configuration, `LambdaConfig` + +When a serverless component does not declare any additional environment variables, but +needs access to the base configuration supplied to all Lambda functions, the +`serverless-common` package provides the `lambdaConfigValues` object of type `LambdaConfig`. +The example below illustrates how to use `lambdaConfigValues`. + +Simply import `lambdaConfigValues` into any module which requires access to the configuration. +This ready to use object is of type `LambdaConfig`. + +```js +// some-component.ts +import { lambdaConfigValues as config } from '@leanstacks/serverless-common'; + +console.log(`The region is ${config.AWS_REGON}`); +``` + +## Extending `LambdaConfig` with custom configuration attributes + +When a Lambda function has custom configuration attributes, simply extend the `LambdaConfig` type +and [Joi](https://joi.dev/) validation schema. The example below illustrates how to extend `LambdaConfig`. + +Create a configuration module in your serverless project. Import the `LambdaConfig` type, the +`lambdaConfigSchema` Joi schema for that type, and the `validateConfig` utility function. + +```js +// my-config.ts +import { LambdaConfig, lambdaConfigSchema, validateConfig } from '@leanstacks/serverless-common'; + +// extend LambdaConfig +type MyConfig = LambdaConfig & { + TABLE_NAME: string; + QUEUE_NAME: string; + EXPIRES_IN_DAYS: number; +}; + +// extend lambdaConfigSchema +const myConfigSchema = lambdaConfigSchema.keys({ + TABLE_NAME: Joi.string().required(), + QUEUE_NAME: Joi.string().required(), + EXPIRES_IN_DAYS: Joi.number().default(30) +}); + +const config: MyConfig = validateConfig(myConfigSchema); +export default config; +``` + +Now the exported `config` object of type `MyConfig` may be used in any other module within the +serverless component. For example... + +```js +// some-component.ts +import config from 'my-config'; + +console.log(`The table name is ${config.TABLE_NAME}`); +``` + +## Performance considerations + +The configuration attributes are validatedby Joi. To ensure that performance is not negatively +impacted, this validation occurs just once, when the configuration module +is first loaded by the module loader. When modules subsequently import the configuration +object, the pre-processed object is loaded without executing the validation process again. From 6ecb32b075dc4074e3aa3e2fd626bfa2f8904922 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sun, 19 Nov 2023 19:53:34 -0500 Subject: [PATCH 02/23] SLSCMN-3 config docs --- docs/services/CONFIG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 2bb9d7d..e63a78c 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -1,4 +1,4 @@ -[:point-left: Back](/README.md) +:point-left: [Back](/README.md) # Configuration From a227fd0d379ecb2bc396f80b2641be0a9d75bc9b Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sun, 19 Nov 2023 19:54:06 -0500 Subject: [PATCH 03/23] SLSCMN-3 config docs --- docs/services/CONFIG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index e63a78c..49ed8df 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -1,4 +1,4 @@ -:point-left: [Back](/README.md) +[:point_left: Back](/README.md) # Configuration From 5536b9bd4343c61b387f7dff775b9395a0d1f621 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sun, 19 Nov 2023 19:55:31 -0500 Subject: [PATCH 04/23] SLSCMN-3 config docs --- docs/services/CONFIG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 49ed8df..d23b147 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -1,4 +1,4 @@ -[:point_left: Back](/README.md) +:point_left: [Back](/README.md) # Configuration From dcd4e8021a070c2b36ca713f96bab415fe579e0a Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sun, 19 Nov 2023 20:00:20 -0500 Subject: [PATCH 05/23] SLSCMN-3 config docs --- docs/services/CONFIG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index d23b147..4ce020e 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -14,7 +14,7 @@ components to validate and access a typed configuration object. AWS supplies a default set of attributes to every Lambda function, e.g. `AWS_REGION`, and you may define additional custom attributes for your functions. -## Using the default configuration, `LambdaConfig` +## Using `LambdaConfig`, the default configuration When a serverless component does not declare any additional environment variables, but needs access to the base configuration supplied to all Lambda functions, the @@ -22,9 +22,9 @@ needs access to the base configuration supplied to all Lambda functions, the The example below illustrates how to use `lambdaConfigValues`. Simply import `lambdaConfigValues` into any module which requires access to the configuration. -This ready to use object is of type `LambdaConfig`. +This ready to use object is of type [`LambdaConfig`](/src/services/config.service.ts). -```js +```ts // some-component.ts import { lambdaConfigValues as config } from '@leanstacks/serverless-common'; @@ -39,7 +39,7 @@ and [Joi](https://joi.dev/) validation schema. The example below illustrates how Create a configuration module in your serverless project. Import the `LambdaConfig` type, the `lambdaConfigSchema` Joi schema for that type, and the `validateConfig` utility function. -```js +```ts // my-config.ts import { LambdaConfig, lambdaConfigSchema, validateConfig } from '@leanstacks/serverless-common'; @@ -54,7 +54,7 @@ type MyConfig = LambdaConfig & { const myConfigSchema = lambdaConfigSchema.keys({ TABLE_NAME: Joi.string().required(), QUEUE_NAME: Joi.string().required(), - EXPIRES_IN_DAYS: Joi.number().default(30) + EXPIRES_IN_DAYS: Joi.number().default(30), }); const config: MyConfig = validateConfig(myConfigSchema); @@ -64,7 +64,7 @@ export default config; Now the exported `config` object of type `MyConfig` may be used in any other module within the serverless component. For example... -```js +```ts // some-component.ts import config from 'my-config'; From eee0fdd87edee9d9c796e50c5dfbc5aa8ce2d935 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 23 Nov 2023 06:12:21 -0500 Subject: [PATCH 06/23] SLSCMN-3 config docs --- docs/services/CONFIG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 4ce020e..07fb112 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -57,6 +57,7 @@ const myConfigSchema = lambdaConfigSchema.keys({ EXPIRES_IN_DAYS: Joi.number().default(30), }); +// validate and process custom configuration const config: MyConfig = validateConfig(myConfigSchema); export default config; ``` @@ -73,7 +74,7 @@ console.log(`The table name is ${config.TABLE_NAME}`); ## Performance considerations -The configuration attributes are validatedby Joi. To ensure that performance is not negatively +The configuration attributes are validated by Joi. To ensure that performance is not negatively impacted, this validation occurs just once, when the configuration module is first loaded by the module loader. When modules subsequently import the configuration object, the pre-processed object is loaded without executing the validation process again. From 48ec45d4d7a472342ef58bf197bc6ca9d77588b4 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 23 Nov 2023 06:38:32 -0500 Subject: [PATCH 07/23] SLSCMN-3 docs --- README.md | 22 ++++++++++++++++++++++ docs/DOCS.md | 9 +++++++++ docs/services/CONFIG.md | 2 +- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docs/DOCS.md diff --git a/README.md b/README.md index 834357e..f6c6841 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ # @leanstacks/serverless-common A suite of common components used to compose serverless application components for the LeanStacks organization. + +## License + +[MIT License](./LICENSE) + +## Requirements + +This library requires the following: + +- Node >= 18.17.x + +## Install + +To install this library, issue the following command in your AWS Serverless project: + +``` +npm install @leanstacks/serverless-common +``` + +## Documentation + +Read the [documentation](/docs/DOCS.md). diff --git a/docs/DOCS.md b/docs/DOCS.md new file mode 100644 index 0000000..d73fa1d --- /dev/null +++ b/docs/DOCS.md @@ -0,0 +1,9 @@ +:house: [Home](/) + +# @leanstacks/serverless-common Documentation + +The documentation is organized into sections by module. + +## Table of Contents + +1. [Configuration](/docs/services/CONFIG.md) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 07fb112..5c1d3f8 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -1,4 +1,4 @@ -:point_left: [Back](/README.md) +:house: [Home](/) | :books: [Docs](../DOCS.md) # Configuration From e214c19db50577a01d5bd4471de2191bf9bda581 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 23 Nov 2023 07:02:52 -0500 Subject: [PATCH 08/23] SLSCMN-3 docs --- docs/DOCS.md | 2 +- docs/services/CONFIG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DOCS.md b/docs/DOCS.md index d73fa1d..e605768 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -1,4 +1,4 @@ -:house: [Home](/) +:house: [Home](/README.md) # @leanstacks/serverless-common Documentation diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 5c1d3f8..60ecd81 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -1,4 +1,4 @@ -:house: [Home](/) | :books: [Docs](../DOCS.md) +:house: [Home](/README.md) | :books: [Docs](../DOCS.md) # Configuration From e1ba3f8ae6849468fd6644641c720e5a16147511 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 23 Nov 2023 08:37:43 -0500 Subject: [PATCH 09/23] SLSCMN-3 middy docs --- docs/DOCS.md | 1 + docs/utils/MIDDYFY.md | 129 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 docs/utils/MIDDYFY.md diff --git a/docs/DOCS.md b/docs/DOCS.md index e605768..d1d31f6 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -7,3 +7,4 @@ The documentation is organized into sections by module. ## Table of Contents 1. [Configuration](/docs/services/CONFIG.md) +1. [Handlers](/docs/utils/MIDDYFY.md) diff --git a/docs/utils/MIDDYFY.md b/docs/utils/MIDDYFY.md new file mode 100644 index 0000000..5379c89 --- /dev/null +++ b/docs/utils/MIDDYFY.md @@ -0,0 +1,129 @@ +:house: [Home](/README.md) | :books: [Docs](../DOCS.md) + +# Middyfy + +This document describes how to create a serverless event handler component leveraging +shared components from the [`serverless-common`](/) package. + +## Why middleware? + +By applying a standard suite of configurable middleware to your AWS Lambda functions, you can skip the boilerplate code and focus on the business logic of the function. + +## How it works + +All AWS Lambda functions share the same handler function shape... + +```ts +export type Handler = ( + event: TEvent, + context: Context, + callback: Callback, +) => void | Promise; +``` + +Middy provides the ability to wrap AWS Lambda handler functions in middleware. Each middleware provides the ability to act on the event _before_ the handler, act on the result _after_ the handler, or to act on unhandled errors thrown from the handler. + +Want to know more? Read the [official Middy guide for how middleware works](https://middy.js.org/docs/intro/how-it-works). + +## Creating a simple API Gateway event handler + +To create a simplistic API Gateway event handler without event validation, you will need to create two modules: the handler function and the middyfy wrapper. + +Create the AWS Lambda Handler function with the usual signature... + +> **NOTE:** Each handler function is located in a dedicated directory to allow the handler function and related middleware components to be bundled and exported in a single `index.ts`. + +_/handlers/task-find/handler.ts_ + +```ts +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { NotFoundError } from '@leanstacks/serverless-common'; +... + +export const handler = async ( + event: APIGatewayProxyEvent, + context: Context, +): Promise => { + // parse request + const taskListId: string = event.pathParameters?.taskListId ?? ''; + const requestor: string = event.requestContext.authorizer?.user_id ?? ''; + + // perform business logic + const taskDataService = new TaskDataService(); + const taskList = await taskDataService.find(taskListId, requestor); + + // create response + if (taskList) { + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': config.ALLOW_ORIGIN, + }, + body: JSON.stringify(taskList), + }; + } else { + throw new NotFoundError(); + } +}; +``` + +Next, we need to _"middyfy"_ the handler function. This is just a fancy way of saying we are wrapping the handler function with middleware. + +_/handlers/task-find/index.ts_ + +```ts +import { middyfyAPIGateway } from '@leanstacks/serverless-common'; + +import { handler } from './handler'; + +export const handle = middyfyAPIGateway({ handler }); +``` + +The handler function appears much the same as what you may be writing already. So what is advantage? The magic happens in the `middyfyAPIGateway` function. This simple function wraps the handler in several middlewares. + +First, the [`http-event-normalizer`](https://middy.js.org/docs/middlewares/http-event-normalizer) official Middy middleware ensures that all of the optional elements of an APIGatewayProxyEvent are defined even when empty. + +Next, the [`http-json-body-parser`](https://middy.js.org/docs/middlewares/http-json-body-parser) official Middy middleware parses the HTTP request body converting it from a string of JSON into an object. + +Then, the `validator-joi` middleware validates the APIGatewayProxyEvent with a Joi schema, when a schema is provided in the middleware options. + +Finally, the `http-error-handler` middleware processes errors thrown from the handler function, creating a standardized response based upon the error type. + +## Creating an API Gateway handler with event validation + +In the section above, we discussed how to create an API Gateway event handler function in the most simplistic form. Let's add event validation to ensure that the request contains the required information and throw an error if it does not. + +Building on the example from the previous section, simply create a [Joi](https://joi.dev/) schema. + +_/handlers/task-find/schema.ts_ + +```ts +import { APIGatewayProxyEvent } from 'aws-lambda'; +import * as Joi from 'joi'; + +export const eventSchema = Joi.object({ + pathParameters: Joi.object({ + taskListId: Joi.string().required(), + }), + requestContext: Joi.object({ + authorizer: Joi.object({ + user_id: Joi.string().required(), + }), + }), +}); +``` + +So that our middleware will validate the API Gateway event, we must pass the schema in the middleware options in `index.ts` as illustrated below. + +```ts +import { middyfyAPIGateway } from '@leanstacks/serverless-common'; + +import { handler } from './handler'; +import { eventSchema } from './schema'; + +export const handle = middyfyAPIGateway({ handler, eventSchema }); +``` + +When an `eventSchema` is present in the middleware options, the Joi Validator middleware ensures that the event is valid. The middleware throws a ValidationError if it is invalid. The HTTP Error Handler knows how to handle a ValidationError, returning a response with status code 400 and an informative message. + +Notice that nothing changed with the handler function itself! From 54106fa6520acc5ee37abfce1e80b6d46d148ce7 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 23 Nov 2023 08:43:30 -0500 Subject: [PATCH 10/23] SLSCMN-3 docs --- docs/services/CONFIG.md | 2 +- docs/utils/MIDDYFY.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 60ecd81..73a5053 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -3,7 +3,7 @@ # Configuration This document describes how to configure a serverless application component leveraging -shared components from the [`serverless-common`](/) package. +shared components from the `serverless-common` package. ## How it works diff --git a/docs/utils/MIDDYFY.md b/docs/utils/MIDDYFY.md index 5379c89..e5d0cc6 100644 --- a/docs/utils/MIDDYFY.md +++ b/docs/utils/MIDDYFY.md @@ -3,7 +3,7 @@ # Middyfy This document describes how to create a serverless event handler component leveraging -shared components from the [`serverless-common`](/) package. +shared components from the `serverless-common` package. ## Why middleware? @@ -21,7 +21,7 @@ export type Handler = ( ) => void | Promise; ``` -Middy provides the ability to wrap AWS Lambda handler functions in middleware. Each middleware provides the ability to act on the event _before_ the handler, act on the result _after_ the handler, or to act on unhandled errors thrown from the handler. +Middy provides the ability to wrap AWS Lambda handler functions in middleware. Each middleware provides the ability to act on the event _before_ the handler, act on the result _after_ the handler, or to act on unhandled errors thrown from the handler or middleware. Want to know more? Read the [official Middy guide for how middleware works](https://middy.js.org/docs/intro/how-it-works). From 8a5647338e93f648f5b0d63484a6eacb65cf3786 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 24 Nov 2023 06:29:04 -0500 Subject: [PATCH 11/23] SLSCMN-3 dynamo docs --- docs/DOCS.md | 3 +- docs/services/DYNAMO.md | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 docs/services/DYNAMO.md diff --git a/docs/DOCS.md b/docs/DOCS.md index d1d31f6..7653f10 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -6,5 +6,6 @@ The documentation is organized into sections by module. ## Table of Contents -1. [Configuration](/docs/services/CONFIG.md) 1. [Handlers](/docs/utils/MIDDYFY.md) +1. [Configuration](/docs/services/CONFIG.md) +1. [DynamoDB Client](/docs//services/DYNAMO.md) diff --git a/docs/services/DYNAMO.md b/docs/services/DYNAMO.md new file mode 100644 index 0000000..c54134d --- /dev/null +++ b/docs/services/DYNAMO.md @@ -0,0 +1,63 @@ +:house: [Home](/README.md) | :books: [Docs](../DOCS.md) + +# DynamoDB Client Service + +This document describes how to use the DynamoDB Client service component, `DynamoService`, to perform actions on the items within a table. + +## How it works + +The AWS SDK provides components which facilitate operations on DynamoDB tables. These components must be configured and instantiated before use. This boilerplate setup logic is encapsulated within the `DynamoService` component. + +## Using `DynamoService` + +The `DynamoService` wraps the `DynamoDBClient` and `DynamoDBDocumentClient` from the AWS SDK, exposing the functions which you need to interact with DynamoDB tables. Those functions include: + +- batchWrite +- delete +- get +- put +- query +- scan +- update + +`DynamoService` leverages the family of `CommandInput` types from the AWS SDK for the function parameters and return types. For example, the signature of the `get` function is: + +```ts +get(input: GetCommandInput): Promise +``` + +For more information about the CommandInput and CommandOutput objects see the [`@aws-sdk/lib-dynamodb`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-lib-dynamodb/) official documentation. + +In the example below, the `find` function accepts an identifier and uses `DynamoService.get` to search the table for the item. + +First, we get the `DynamoService` instance. `DynamoService` is a singleton to improve performance across invocations of the Lambda function. + +```ts +const dynamoService: DynamoService = DynamoService.getInstance(); +``` + +Then the service is used to fetch an item from the table by the table key. + +```ts +async find(taskListId: string): Promise { + const output = await this.dynamoService.get({ + TableName: config.TASK_LIST_TABLE, + Key: { + taskListId, + }, + }); + + if (output.Item) { + return output.Item as TaskList; + } else { + // not found + return null; + } +} +``` + +Notice the shape of the input object to the `get` function is the `GetCommandInput` from the AWS SDK. Furthermore, the shape of the returned object is the `GetCommandOutput`. The same pattern applies to the other functions of `DynamoService`. + +## Performance considerations + +The Dynamo components are constructed and configured once, when the dynamo module is first loaded by the module loader. Furthermore, the components are preserved between function invocations. When other modules subsequently import the DynamoService, the component is loaded without constructing the client components again. From 3de39da6602ef54039388d2c0bd585d79d2fd63a Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 24 Nov 2023 07:34:13 -0500 Subject: [PATCH 12/23] SLSCMN-3 error docs --- docs/DOCS.md | 3 +- docs/errors/ERRORS.md | 151 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 docs/errors/ERRORS.md diff --git a/docs/DOCS.md b/docs/DOCS.md index 7653f10..6fcd8a8 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -8,4 +8,5 @@ The documentation is organized into sections by module. 1. [Handlers](/docs/utils/MIDDYFY.md) 1. [Configuration](/docs/services/CONFIG.md) -1. [DynamoDB Client](/docs//services/DYNAMO.md) +1. [DynamoDB Client](/docs/services/DYNAMO.md) +1. [Errors](/docs/errors/ERRORS.md) diff --git a/docs/errors/ERRORS.md b/docs/errors/ERRORS.md new file mode 100644 index 0000000..0041e3e --- /dev/null +++ b/docs/errors/ERRORS.md @@ -0,0 +1,151 @@ +:house: [Home](/README.md) | :books: [Docs](../DOCS.md) + +# Errors + +This document describes how to use the family of `Error` classes within the `@leanstacks/serverless-common` package. + +## How it works + +The `@leanstacks/serverless-common` package provides a family of error classes which may be utilized within AWS Lambda functions to ensure that a consistent response is returned when exception scenarios occur. + +## Using the errors + +There are two base error classes: `ServiceError` and `HttpError`. Both extend `Error` and, therefore, both have `name` and `message` properties. Additional properties are included to provide more information. + +### `ServiceError` + +A `ServiceError` may be used in any type of AWS Lambda function. The error has the following properties: + +- `name` -- The string "ServiceError". +- `message` -- A string message describing the error. +- `statusCode` -- Optional. A numeric [HTTP response status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) +- `code` -- Optional. A numeric value to convey further meaning to the API client regarding the cause of the error + +A `ServiceError` may be constructed with either a string or an Error as the first parameter. The optional second parameter is the numeric `code`. And, the optional third parameter is the `statusCode`. + +_Example: Throw a `ServiceError` with a message._ + +```ts +new ServiceError('Object not found in S3 bucket'); +``` + +_Example: Throw a `ServiceError` with an error and code._ + +```ts +try { + ... +} catch (err) { + throw new ServiceError(err, 1001); +} +``` + +### `HttpError` + +A `HttpError` and, more often, it's sub-classes are used in API Gateway Lambda functions. The error has the following properties: + +- `name` -- The string "HttpError". +- `message` -- A string message describing the error. +- `statusCode` -- A numeric [HTTP response status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) + +A `HttpError` may be constructed with either a string or an Error as the first parameter and a status code as the second parameter. + +_Example: Throw a `HttpError` with a message and status code._ + +```ts +new HttpError('Not found', 404); +``` + +_Example: Throw a `HttpError` with an error and status code._ + +```ts +try { + ... +} catch (err) { + throw new HttpError(err, 500); +} +``` + +However, it is more common to use one of the `HttpError` sub-classes: + +- `BadRequestError` +- `ForbiddenError` +- `NotFoundError` + +### `BadRequestError` + +A `BadRequestError` extends `HttpError`. The error `name` is "BadRequestError" and the `statusCode` is 400. + +The `BadRequestError` constructor accepts an optional string `message` parameter. When provided, this value is set to the error `message` property, otherwise, the default HTTP status message for HTTP 400 is used. + +_Example: Throw a `BadRequestError` with the default message._ + +```ts +new BadRequestError(); +``` + +_Example: Throw a `BadRequestError` with a custom message._ + +```ts +try { + ... +} catch (err) { + throw new BadRequestError(err.message); +} +``` + +### `ForbiddenError` + +A `ForbiddenError` extends `HttpError`. The error `name` is "ForbiddenError" and the `statusCode` is 403. + +The `ForbiddenError` constructor accepts an optional string `message` parameter. When provided, this value is set to the error `message` property, otherwise, the default HTTP status message for HTTP 403 is used. + +_Example: Throw a `ForbiddenError` with the default message._ + +```ts +new ForbiddenError(); +``` + +_Example: Throw a `ForbiddenError` with a custom message._ + +```ts +try { + ... +} catch (err) { + throw new ForbiddenError(err.message); +} +``` + +### `NotFoundError` + +A `NotFoundError` extends `HttpError`. The error `name` is "NotFoundError" and the `statusCode` is 404. + +The `NotFoundError` constructor accepts an optional string `message` parameter. When provided, this value is set to the error `message` property, otherwise, the default HTTP status message for HTTP 404 is used. + +_Example: Throw a `NotFoundError` with the default message._ + +```ts +new NotFoundError(); +``` + +_Example: Throw a `NotFoundError` with a custom message._ + +```ts +try { + ... +} catch (err) { + throw new NotFoundError(err.message); +} +``` + +## Example API client response + +When any of the error family of classes is thrown from an API Gateway Lambda function, the `http-error-handler` middleware intercepts the error and, using the information within the error, creates an informative response for the API client. The response body payload is similar to the example below. + +````json +{ + "name": "NotFoundError", + "message": "The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.", + "code": 404, + "statusCode": 404 +}``` +```` From 8a1bcd91fb47b82d2170b8611cffe7c6051c2320 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 24 Nov 2023 07:35:41 -0500 Subject: [PATCH 13/23] SLSCMN-3 error docs --- docs/errors/ERRORS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/errors/ERRORS.md b/docs/errors/ERRORS.md index 0041e3e..b47021b 100644 --- a/docs/errors/ERRORS.md +++ b/docs/errors/ERRORS.md @@ -141,11 +141,11 @@ try { When any of the error family of classes is thrown from an API Gateway Lambda function, the `http-error-handler` middleware intercepts the error and, using the information within the error, creates an informative response for the API client. The response body payload is similar to the example below. -````json +```json { "name": "NotFoundError", "message": "The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.", "code": 404, "statusCode": 404 -}``` -```` +} +``` From b344e5a751d9c9fbd04656818744b3f2b4dd90ac Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 06:47:17 -0500 Subject: [PATCH 14/23] SLSCMN-3 readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f6c6841..b379c2e 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,4 @@ npm install @leanstacks/serverless-common ## Documentation -Read the [documentation](/docs/DOCS.md). +For more detailed information regarding specific components see the [documentation](/docs/DOCS.md). From 4ca7886ec889138ab64e67f5a4e428dd2127a4ce Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 07:25:38 -0500 Subject: [PATCH 15/23] SLSCMN-3 config service tests --- jest.config.ts | 2 +- jest.setup.ts | 6 ++++++ .../__tests__/config.service-failure.test.ts | 20 +++++++++++++++++++ src/services/__tests__/config.service.test.ts | 20 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 jest.setup.ts create mode 100644 src/services/__tests__/config.service-failure.test.ts create mode 100644 src/services/__tests__/config.service.test.ts diff --git a/jest.config.ts b/jest.config.ts index 075fb22..19c85e2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -138,7 +138,7 @@ export default { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: ['/jest.setup.ts'], + setupFiles: ['/jest.setup.ts'], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..8c49444 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,6 @@ +// declare env vars +process.env.AWS_EXECUTION_ENV = 'aws-execution-env'; +process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '128'; +process.env.AWS_LAMBDA_FUNCTION_NAME = 'aws-lambda-function-name'; +process.env.AWS_LAMBDA_FUNCTION_VERSION = 'aws-lambda-function-version'; +process.env.AWS_REGION = 'aws-region'; diff --git a/src/services/__tests__/config.service-failure.test.ts b/src/services/__tests__/config.service-failure.test.ts new file mode 100644 index 0000000..9abfcd0 --- /dev/null +++ b/src/services/__tests__/config.service-failure.test.ts @@ -0,0 +1,20 @@ +import { ServiceError } from '../../errors/service.error'; +import { LambdaConfig, lambdaConfigSchema, validateConfig } from '../config.service'; + +describe('ConfigService Failure', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + delete process.env.AWS_REGION; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should throw error when validation fails', () => { + expect(() => validateConfig(lambdaConfigSchema)).toThrow(ServiceError); + }); +}); diff --git a/src/services/__tests__/config.service.test.ts b/src/services/__tests__/config.service.test.ts new file mode 100644 index 0000000..4939812 --- /dev/null +++ b/src/services/__tests__/config.service.test.ts @@ -0,0 +1,20 @@ +import { LambdaConfig, lambdaConfigSchema, validateConfig } from '../config.service'; + +describe('ConfigService', () => { + it('should validate successfully', () => { + const validatedConfig = validateConfig(lambdaConfigSchema); + + expect(validatedConfig).toBeDefined(); + }); + + it('should return LambdaConfig attributes', () => { + const validatedConfig = validateConfig(lambdaConfigSchema); + + expect(validatedConfig).toBeDefined(); + expect(validatedConfig.AWS_EXECUTION_ENV).toBe('aws-execution-env'); + expect(validatedConfig.AWS_LAMBDA_FUNCTION_MEMORY_SIZE).toBe('128'); + expect(validatedConfig.AWS_LAMBDA_FUNCTION_NAME).toBe('aws-lambda-function-name'); + expect(validatedConfig.AWS_LAMBDA_FUNCTION_VERSION).toBe('aws-lambda-function-version'); + expect(validatedConfig.AWS_REGION).toBe('aws-region'); + }); +}); From 647d673bc37d45caad4fe8ef0b4c9e02e97592ad Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 07:28:27 -0500 Subject: [PATCH 16/23] SLSCMN-3 exclude jest source in rollup config --- rollup.config.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rollup.config.js b/rollup.config.js index 0830ef6..4ecb578 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -28,7 +28,13 @@ export default [ commonjs(), typescript({ tsconfig: './tsconfig.json', - exclude: ['jest.config.ts', '**/*.test.ts', '**/__tests__/**/*', '**/__fixtures__/**/*'], + exclude: [ + 'jest.config.ts', + 'jest.setup.ts', + '**/*.test.ts', + '**/__tests__/**/*', + '**/__fixtures__/**/*', + ], }), terser(), ], From c1c6d56c95f85522fd3c8c1d550b2183cb6cccd8 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 07:28:47 -0500 Subject: [PATCH 17/23] SLSCMN-3 move middleware tests --- src/middlewares/{ => __tests__}/error-handler-http.test.ts | 6 +++--- src/middlewares/{ => __tests__}/validator-joi.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/middlewares/{ => __tests__}/error-handler-http.test.ts (94%) rename src/middlewares/{ => __tests__}/validator-joi.test.ts (91%) diff --git a/src/middlewares/error-handler-http.test.ts b/src/middlewares/__tests__/error-handler-http.test.ts similarity index 94% rename from src/middlewares/error-handler-http.test.ts rename to src/middlewares/__tests__/error-handler-http.test.ts index 1245ed9..de1a3ee 100644 --- a/src/middlewares/error-handler-http.test.ts +++ b/src/middlewares/__tests__/error-handler-http.test.ts @@ -1,10 +1,10 @@ import { Request } from '@middy/core'; import { jest } from '@jest/globals'; -import { HttpError, ServiceError } from '../errors'; +import { HttpError, ServiceError } from '../../errors'; -import { httpErrorHandler } from './error-handler-http'; -import { requestFixture } from '../__fixtures__/middy.fixture'; +import { httpErrorHandler } from '../error-handler-http'; +import { requestFixture } from '../../__fixtures__/middy.fixture'; describe('HttpErrorHandler', () => { it('should create the middleware', () => { diff --git a/src/middlewares/validator-joi.test.ts b/src/middlewares/__tests__/validator-joi.test.ts similarity index 91% rename from src/middlewares/validator-joi.test.ts rename to src/middlewares/__tests__/validator-joi.test.ts index 8f311d9..ef8e106 100644 --- a/src/middlewares/validator-joi.test.ts +++ b/src/middlewares/__tests__/validator-joi.test.ts @@ -1,10 +1,10 @@ import { jest } from '@jest/globals'; import { Request } from '@middy/core'; import * as Joi from 'joi'; -import { BadRequestError } from '../errors/bad-request.error'; +import { BadRequestError } from '../../errors/bad-request.error'; -import { validator } from './validator-joi'; -import { requestFixture } from '../__fixtures__/middy.fixture'; +import { validator } from '../validator-joi'; +import { requestFixture } from '../../__fixtures__/middy.fixture'; describe('JoiValidator', () => { const schema = Joi.object({ From 14036691e575e3836a0179fa124cd7d8301e3c89 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 07:34:48 -0500 Subject: [PATCH 18/23] SLSCMN-3 organize tests --- jest.config.ts | 2 +- src/errors/{ => __tests__}/bad-request.error.test.ts | 4 ++-- src/errors/{ => __tests__}/forbidden.error.test.ts | 4 ++-- src/errors/{ => __tests__}/http.error.test.ts | 2 +- src/errors/{ => __tests__}/not-found-error.test.ts | 4 ++-- src/errors/{ => __tests__}/service.error.test.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename src/errors/{ => __tests__}/bad-request.error.test.ts (87%) rename src/errors/{ => __tests__}/forbidden.error.test.ts (87%) rename src/errors/{ => __tests__}/http.error.test.ts (92%) rename src/errors/{ => __tests__}/not-found-error.test.ts (87%) rename src/errors/{ => __tests__}/service.error.test.ts (94%) diff --git a/jest.config.ts b/jest.config.ts index 19c85e2..bdc14b6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -26,7 +26,7 @@ export default { coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection - coveragePathIgnorePatterns: ['/node_modules/', '__fixtures__'], + coveragePathIgnorePatterns: ['/node_modules/', '__fixtures__', 'index.ts'], // Indicates which provider should be used to instrument code for coverage // coverageProvider: "v8", diff --git a/src/errors/bad-request.error.test.ts b/src/errors/__tests__/bad-request.error.test.ts similarity index 87% rename from src/errors/bad-request.error.test.ts rename to src/errors/__tests__/bad-request.error.test.ts index 1d6784d..e2db834 100644 --- a/src/errors/bad-request.error.test.ts +++ b/src/errors/__tests__/bad-request.error.test.ts @@ -1,7 +1,7 @@ import httpStatus from 'http-status'; -import { BadRequestError } from './bad-request.error'; -import { HttpError } from './http.error'; +import { BadRequestError } from '../bad-request.error'; +import { HttpError } from '../http.error'; describe('BadRequestError', () => { it('should use constructor values', () => { diff --git a/src/errors/forbidden.error.test.ts b/src/errors/__tests__/forbidden.error.test.ts similarity index 87% rename from src/errors/forbidden.error.test.ts rename to src/errors/__tests__/forbidden.error.test.ts index 96e2f6f..b43a344 100644 --- a/src/errors/forbidden.error.test.ts +++ b/src/errors/__tests__/forbidden.error.test.ts @@ -1,7 +1,7 @@ import httpStatus from 'http-status'; -import { ForbiddenError } from './forbidden.error'; -import { HttpError } from './http.error'; +import { ForbiddenError } from '../forbidden.error'; +import { HttpError } from '../http.error'; describe('ForbiddenError', () => { it('should use constructor values', () => { diff --git a/src/errors/http.error.test.ts b/src/errors/__tests__/http.error.test.ts similarity index 92% rename from src/errors/http.error.test.ts rename to src/errors/__tests__/http.error.test.ts index 7accf34..0abbd9c 100644 --- a/src/errors/http.error.test.ts +++ b/src/errors/__tests__/http.error.test.ts @@ -1,4 +1,4 @@ -import { HttpError } from './http.error'; +import { HttpError } from '../http.error'; describe('HttpError', () => { it('should use constructor values', () => { diff --git a/src/errors/not-found-error.test.ts b/src/errors/__tests__/not-found-error.test.ts similarity index 87% rename from src/errors/not-found-error.test.ts rename to src/errors/__tests__/not-found-error.test.ts index 6bf59e3..5aba763 100644 --- a/src/errors/not-found-error.test.ts +++ b/src/errors/__tests__/not-found-error.test.ts @@ -1,7 +1,7 @@ import httpStatus from 'http-status'; -import { NotFoundError } from './not-found.error'; -import { HttpError } from './http.error'; +import { NotFoundError } from '../not-found.error'; +import { HttpError } from '../http.error'; describe('NotFoundError', () => { it('should use constructor values', () => { diff --git a/src/errors/service.error.test.ts b/src/errors/__tests__/service.error.test.ts similarity index 94% rename from src/errors/service.error.test.ts rename to src/errors/__tests__/service.error.test.ts index 9f0ada4..6c38aeb 100644 --- a/src/errors/service.error.test.ts +++ b/src/errors/__tests__/service.error.test.ts @@ -1,4 +1,4 @@ -import { ServiceError } from './service.error'; +import { ServiceError } from '../service.error'; describe('ServiceError', () => { it('should use constructor values', () => { From 99acf5ef33b49ccb3eb82c2e36827f840c627de0 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 08:12:44 -0500 Subject: [PATCH 19/23] SLSCMN-3 middyfy tests --- src/utils/__tests__/middyfy.test.ts | 28 ++++++++++++++++++++++++++++ src/utils/middyfy.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/utils/__tests__/middyfy.test.ts diff --git a/src/utils/__tests__/middyfy.test.ts b/src/utils/__tests__/middyfy.test.ts new file mode 100644 index 0000000..8507d95 --- /dev/null +++ b/src/utils/__tests__/middyfy.test.ts @@ -0,0 +1,28 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context, ScheduledEvent } from 'aws-lambda'; + +import { middyfyAPIGateway, middyfyScheduled } from '../middyfy'; + +describe('middyfyAPIGateway', () => { + const handlerFn = async ( + event: APIGatewayProxyEvent, + context: Context, + ): Promise => { + return { statusCode: 200, body: '' }; + }; + + it('should midfyfyAPIGateway', () => { + const handler = middyfyAPIGateway({ handler: handlerFn }); + + expect(handler).toBeDefined(); + }); +}); + +describe('middyfyScheduled', () => { + const handlerFn = async (event: ScheduledEvent, context: Context): Promise => {}; + + it('should middyfyScheduled', () => { + const handler = middyfyScheduled({ handler: handlerFn }); + + expect(handler).toBeDefined(); + }); +}); diff --git a/src/utils/middyfy.ts b/src/utils/middyfy.ts index 3c076fe..538a760 100644 --- a/src/utils/middyfy.ts +++ b/src/utils/middyfy.ts @@ -7,28 +7,50 @@ import { ObjectSchema } from 'joi'; import { validator } from '../middlewares/validator-joi'; import { httpErrorHandler } from '../middlewares/error-handler-http'; +/** + * The AWS Lambda handler function signature for API Gateway proxy events. + */ export type APIGatewayHandlerFn = ( event: APIGatewayProxyEvent, context: Context, ) => Promise; +/** + * The AWS Lambda handler function signature for Scheduled events + * (e.g. cron events from AWS EventBridge). + */ export type ScheduledHandlerFn = (event: ScheduledEvent, context: Context) => Promise; +/** + * Base options for `middyfy` functions. + */ export type MiddyfyOptions = { handler: THandler; logger?: (message: string) => void; }; +/** + * Options for middyfied API Gateway event handler functions. + */ export type APIGatewayMiddyfyOptions = MiddyfyOptions & { eventSchema?: ObjectSchema; defaultErrorMessage?: string; defaultErrorStatusCode?: number; }; +/** + * Options for middyfied Scheduled event handler functions. + */ export type ScheduledMiddyfyOptions = MiddyfyOptions & { eventSchema?: ObjectSchema; }; +/** + * Wraps an AWS Gateway proxy event handler function in middleware, returning the + * AWS Lambda handler function. + * @param options - The `APIGatewayMiddyfyOptions` object. + * @returns A middyfied handler function. + */ export const middyfyAPIGateway = (options: APIGatewayMiddyfyOptions) => { return middy(options.handler) .use(httpEventNormalizer()) @@ -43,6 +65,12 @@ export const middyfyAPIGateway = (options: APIGatewayMiddyfyOptions) => { ); }; +/** + * Wraps a Scheduled event handler function in middleware, returning the + * AWS Lambda handler function. + * @param options - The `ScheduledMiddyfyOptions` object. + * @returns A middyfied handler function. + */ export const middyfyScheduled = (options: ScheduledMiddyfyOptions) => { return middy(options.handler).use( validator({ eventSchema: options.eventSchema, logger: options.logger }), From dcd2c8d71f86cd6d42def9abed3c7cb70da95ef5 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 08:24:45 -0500 Subject: [PATCH 20/23] SLSCMN-3 jsdoc --- src/errors/bad-request.error.ts | 5 +++++ src/errors/forbidden.error.ts | 5 +++++ src/errors/http.error.ts | 4 ++++ src/errors/not-found.error.ts | 5 +++++ src/errors/service.error.ts | 4 ++++ src/middlewares/error-handler-http.ts | 9 +++++++++ 6 files changed, 32 insertions(+) diff --git a/src/errors/bad-request.error.ts b/src/errors/bad-request.error.ts index c0787a4..d7a45db 100644 --- a/src/errors/bad-request.error.ts +++ b/src/errors/bad-request.error.ts @@ -2,6 +2,11 @@ import httpStatus from 'http-status'; import { HttpError } from './http.error'; +/** + * `BadRequestError` extends `HttpError` creating a specialized Error class for the + * Bad Request (400) HTTP response code. + * @see {@link HttpError} + */ export class BadRequestError extends HttpError { name = 'BadRequestError'; diff --git a/src/errors/forbidden.error.ts b/src/errors/forbidden.error.ts index d3d38a3..9c08204 100644 --- a/src/errors/forbidden.error.ts +++ b/src/errors/forbidden.error.ts @@ -2,6 +2,11 @@ import httpStatus from 'http-status'; import { HttpError } from './http.error'; +/** + * `Forbidden` extends `HttpError` creating a specialized Error class for the + * Forbidden (403) HTTP response code. + * @see {@link HttpError} + */ export class ForbiddenError extends HttpError { name = 'ForbiddenError'; diff --git a/src/errors/http.error.ts b/src/errors/http.error.ts index 6111815..4d9039d 100644 --- a/src/errors/http.error.ts +++ b/src/errors/http.error.ts @@ -1,3 +1,7 @@ +/** + * The `HttpError` class extends `Error` providing additional, standardized attributes + * for AWS Lambda functions. The attributes include: `statusCode`. + */ export class HttpError extends Error { name = 'HttpError'; statusCode = 500; diff --git a/src/errors/not-found.error.ts b/src/errors/not-found.error.ts index 30454b0..8a53797 100644 --- a/src/errors/not-found.error.ts +++ b/src/errors/not-found.error.ts @@ -2,6 +2,11 @@ import httpStatus from 'http-status'; import { HttpError } from './http.error'; +/** + * `NotFoundError` extends `HttpError` creating a specialized Error class for the + * Not Found (404) HTTP response code. + * @see {@link HttpError} + */ export class NotFoundError extends HttpError { name = 'NotFoundError'; diff --git a/src/errors/service.error.ts b/src/errors/service.error.ts index f8e79e8..9111e0a 100644 --- a/src/errors/service.error.ts +++ b/src/errors/service.error.ts @@ -1,3 +1,7 @@ +/** + * The `ServiceError` class extends `Error` providing additional, standardized attributes + * for AWS Lambda functions. The attributes include: `code` and `statusCode`. + */ export class ServiceError extends Error { name = 'ServiceError'; code = 500; diff --git a/src/middlewares/error-handler-http.ts b/src/middlewares/error-handler-http.ts index 4f7c843..242fd99 100644 --- a/src/middlewares/error-handler-http.ts +++ b/src/middlewares/error-handler-http.ts @@ -4,6 +4,10 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ServiceError } from '../errors/service.error'; import { HttpError } from '../errors/http.error'; +/** + * `HttpErrorHandlerOptions` provide configuration to the `http-error-handler` + * middleware. + */ export type HttpErrorHandlerOptions = { defaultMessage?: string; defaultStatusCode?: number; @@ -18,6 +22,11 @@ const DEFAULT_OPTIONS: HttpErrorHandlerOptions = { defaultStatusCode: DEFAULT_STATUS_CODE, }; +/** + * Create middleware to process errors thrown from AWS Lambda handler functions. + * @param [opts] - Optional. `HttpErrorHandlerOptions` configuration. + * @returns Middleware to process errors thrown handler functions. + */ export const httpErrorHandler = ( opts?: HttpErrorHandlerOptions, ): MiddlewareObj => { From d8bce7b1a7c781a954067c70d0908866c0d94206 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 08:38:55 -0500 Subject: [PATCH 21/23] SLSCMN-3 dynamo service structure --- src/services/dynamo.service.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/services/dynamo.service.ts b/src/services/dynamo.service.ts index c44012c..2c27416 100644 --- a/src/services/dynamo.service.ts +++ b/src/services/dynamo.service.ts @@ -41,6 +41,42 @@ const unmarshallOptions = { }; const translateConfig: TranslateConfig = { marshallOptions, unmarshallOptions }; +console.log(`DYNAMO::CREATE DOCUMENT CLIENT`); +const documentClient = DynamoDBDocumentClient.from( + new DynamoDBClient(dynamoDbClientConfig), + translateConfig, +); + +export const batchWriteItems = ( + input: BatchWriteCommandInput, +): Promise => { + return documentClient.send(new BatchWriteCommand(input)); +}; + +export const deleteItem = (input: DeleteCommandInput): Promise => { + return documentClient.send(new DeleteCommand(input)); +}; + +export const getItem = (input: GetCommandInput): Promise => { + return documentClient.send(new GetCommand(input)); +}; + +export const putItem = (input: PutCommandInput): Promise => { + return documentClient.send(new PutCommand(input)); +}; + +export const queryItems = (input: QueryCommandInput): Promise => { + return documentClient.send(new QueryCommand(input)); +}; + +export const scanItems = (input: ScanCommandInput): Promise => { + return documentClient.send(new ScanCommand(input)); +}; + +export const updateItem = (input: UpdateCommandInput): Promise => { + return documentClient.send(new UpdateCommand(input)); +}; + export class DynamoService { private static instance: DynamoService; private client: DynamoDBDocumentClient; From d38c89c76c4e1f90d52ba9b82c77f65d1e7ead14 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 25 Nov 2023 09:27:42 -0500 Subject: [PATCH 22/23] SLSCMN-3 reshape dynamo service --- src/services/dynamo.service.ts | 127 +++++++++++++++++++-------------- src/services/index.ts | 3 +- 2 files changed, 75 insertions(+), 55 deletions(-) diff --git a/src/services/dynamo.service.ts b/src/services/dynamo.service.ts index 2c27416..d26eca9 100644 --- a/src/services/dynamo.service.ts +++ b/src/services/dynamo.service.ts @@ -41,85 +41,104 @@ const unmarshallOptions = { }; const translateConfig: TranslateConfig = { marshallOptions, unmarshallOptions }; -console.log(`DYNAMO::CREATE DOCUMENT CLIENT`); +/** + * Create a DynamoDBDocumentClient. This will be created once during AWS Lambda function + * cold start and placed in the module loader. It will be reused across function invocations. + */ const documentClient = DynamoDBDocumentClient.from( new DynamoDBClient(dynamoDbClientConfig), translateConfig, ); -export const batchWriteItems = ( - input: BatchWriteCommandInput, -): Promise => { +/** + * Modify a DynamoDB Table with a batch of Put and/or Delete operations. + * @param input A `BatchWriteCommandInput` object. + * @returns A Promise which resolves to a `BatchWriteCommandOputput` object. + */ +const batchWriteItems = (input: BatchWriteCommandInput): Promise => { return documentClient.send(new BatchWriteCommand(input)); }; -export const deleteItem = (input: DeleteCommandInput): Promise => { +/** + * Delete a single item from a table. + * @param input A `DeleteCommandInput` object. + * @returns A Promise which resolves to a `DeleteCommandOutput` object. + */ +const deleteItem = (input: DeleteCommandInput): Promise => { return documentClient.send(new DeleteCommand(input)); }; -export const getItem = (input: GetCommandInput): Promise => { +/** + * Get a single item from a table. + * @param input A `GetCommandInput` object. + * @returns A Promise which resolves to a `GetCommandOutput` object. + */ +const getItem = (input: GetCommandInput): Promise => { return documentClient.send(new GetCommand(input)); }; -export const putItem = (input: PutCommandInput): Promise => { +/** + * Creates or replaces a single item in a table. + * @param input A `PutCommandInput` object. + * @returns A Promise which resolves to a `PutCommandOutput` object. + */ +const putItem = (input: PutCommandInput): Promise => { return documentClient.send(new PutCommand(input)); }; -export const queryItems = (input: QueryCommandInput): Promise => { +/** + * Searches a table for items matching query criteria. + * @param input A `QueryCommandInput` object. + * @returns A Promise which resolves to a `QueryCommandOutput` object. + */ +const queryItems = (input: QueryCommandInput): Promise => { return documentClient.send(new QueryCommand(input)); }; -export const scanItems = (input: ScanCommandInput): Promise => { +/** + * Returns all items from a table with optional filter critera. + * @param input A `ScanCommandInput` object. + * @returns A Promise which resolves to a `ScanCommandOutput` object. + */ +const scanItems = (input: ScanCommandInput): Promise => { return documentClient.send(new ScanCommand(input)); }; -export const updateItem = (input: UpdateCommandInput): Promise => { +/** + * Edits the attributes of an existing item or creates a new item if none exists. Use + * conditional updates to prevent undesired item creation. + * @param input An `UpdateCommandInput` object. + * @returns A Promise which resolves to an `UpdateCommandOutput` object. + */ +const updateItem = (input: UpdateCommandInput): Promise => { return documentClient.send(new UpdateCommand(input)); }; -export class DynamoService { - private static instance: DynamoService; - private client: DynamoDBDocumentClient; - - private constructor() { - this.client = DynamoDBDocumentClient.from( - new DynamoDBClient(dynamoDbClientConfig), - translateConfig, - ); - } - - public static getInstance() { - if (!DynamoService.instance) { - DynamoService.instance = new DynamoService(); - } - return DynamoService.instance; - } - - batchWrite(input: BatchWriteCommandInput): Promise { - return this.client.send(new BatchWriteCommand(input)); - } - - delete(input: DeleteCommandInput): Promise { - return this.client.send(new DeleteCommand(input)); - } - - get(input: GetCommandInput): Promise { - return this.client.send(new GetCommand(input)); - } - - put(input: PutCommandInput): Promise { - return this.client.send(new PutCommand(input)); - } - - query(input: QueryCommandInput): Promise { - return this.client.send(new QueryCommand(input)); - } +/** + * A `DynamoClient` provides operations for accessing and mutating one or more Items + * within an AWS DynamoDB Table. + */ +type DynamoClient = { + batchWriteItems: (input: BatchWriteCommandInput) => Promise; + deleteItem: (input: DeleteCommandInput) => Promise; + getItem: (input: GetCommandInput) => Promise; + putItem: (input: PutCommandInput) => Promise; + queryItems: (input: QueryCommandInput) => Promise; + scanItems: (input: ScanCommandInput) => Promise; + updateItem: (input: UpdateCommandInput) => Promise; +}; - scan(input: ScanCommandInput): Promise { - return this.client.send(new ScanCommand(input)); - } +/** + * Use the `DynamoService` to access or mutate Items within an AWS DynamoDB Table. + */ +const DynamoService: DynamoClient = { + batchWriteItems, + deleteItem, + getItem, + putItem, + queryItems, + scanItems, + updateItem, +}; - update(input: UpdateCommandInput): Promise { - return this.client.send(new UpdateCommand(input)); - } -} +export default DynamoService; diff --git a/src/services/index.ts b/src/services/index.ts index 819dbd6..f278de2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -4,4 +4,5 @@ export { lambdaConfigValues, validateConfig, } from './config.service'; -export { DynamoService } from './dynamo.service'; + +export { default as DynamoService } from './dynamo.service'; From 476df34eddc6f81018a03719184432cb98bb2d62 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sun, 26 Nov 2023 06:56:33 -0500 Subject: [PATCH 23/23] SLSCMN-3 dynamo service tests --- package-lock.json | 447 ++++++++++++++++++ package.json | 2 + src/__fixtures__/dynamo.fixture.ts | 121 +++++ src/services/__tests__/dynamo.service.test.ts | 125 +++++ 4 files changed, 695 insertions(+) create mode 100644 src/__fixtures__/dynamo.fixture.ts create mode 100644 src/services/__tests__/dynamo.service.test.ts diff --git a/package-lock.json b/package-lock.json index 91cde47..e515cc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "@types/jest": "^29.5.4", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", "eslint": "^8.53.0", "http-status": "^1.7.3", "jest": "^29.6.4", @@ -2552,6 +2554,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sinonjs/samsam": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz", + "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@smithy/abort-controller": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.0.13.tgz", @@ -3253,6 +3281,21 @@ "integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==", "dev": true }, + "node_modules/@types/sinon": { + "version": "10.0.20", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.20.tgz", + "integrity": "sha512-2APKKruFNCAZgx3daAyACGzWuJ028VVCUDk6o2rw/Z4PXT0ogwdV4KUegW0MwVs0Zu59auPXbbuBJHF12Sx1Eg==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3687,6 +3730,285 @@ "node": ">=8" } }, + "node_modules/aws-sdk-client-mock": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-3.0.0.tgz", + "integrity": "sha512-4mBiWhuLYLZe1+K/iB8eYy5SAZyW2se+Keyh5u9QouMt6/qJ5SRZhss68xvUX5g3ApzROJ06QPRziYHP6buuvQ==", + "dev": true, + "dependencies": { + "@types/sinon": "^10.0.10", + "sinon": "^14.0.2", + "tslib": "^2.1.0" + } + }, + "node_modules/aws-sdk-client-mock-jest": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aws-sdk-client-mock-jest/-/aws-sdk-client-mock-jest-3.0.0.tgz", + "integrity": "sha512-oV1rBQZc4UumLbzZAhi8UAehUq+k75hkQYGLrVIP0iJj85Z9xw+EaSsmJke/KQ8Z3vng+Xv1xbounsxpvZpunQ==", + "dev": true, + "dependencies": { + "@types/jest": "^28.1.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "aws-sdk-client-mock": "3.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/@jest/expect-utils": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-28.1.3.tgz", + "integrity": "sha512-wvbi9LUrHJLn3NlDW6wF2hvIMtd4JUl2QNVrjq+IBSHirgfrR3o9RnVtxzdEGO2n9JyIWwHnLfby5KzqBGg2YA==", + "dev": true, + "dependencies": { + "jest-get-type": "^28.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "dev": true + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/@types/jest": { + "version": "28.1.8", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.8.tgz", + "integrity": "sha512-8TJkV++s7B6XqnDrzR1m/TT0A0h948Pnl/097veySPN67VRAgQ4gZ7n2KfJo2rVq6njQjdxU3GCCyDvAeuHoiw==", + "dev": true, + "dependencies": { + "expect": "^28.0.0", + "pretty-format": "^28.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/diff-sequences": { + "version": "28.1.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", + "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/expect": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", + "integrity": "sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^28.1.3", + "jest-get-type": "^28.0.2", + "jest-matcher-utils": "^28.1.3", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/jest-diff": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz", + "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^28.1.1", + "jest-get-type": "^28.0.2", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/jest-get-type": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", + "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/jest-matcher-utils": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz", + "integrity": "sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^28.1.3", + "jest-get-type": "^28.0.2", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "dev": true, + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-sdk-client-mock-jest/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -5396,6 +5718,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7281,6 +7609,12 @@ "node": ">=6" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7339,6 +7673,12 @@ "node": ">=8" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7514,6 +7854,28 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7769,6 +8131,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8196,6 +8567,82 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sinon": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz", + "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==", + "deprecated": "16.1.1", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^9.1.2", + "@sinonjs/samsam": "^7.0.1", + "diff": "^5.0.0", + "nise": "^5.1.2", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", diff --git a/package.json b/package.json index b1e0932..5db1708 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "@types/jest": "^29.5.4", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", + "aws-sdk-client-mock": "^3.0.0", + "aws-sdk-client-mock-jest": "^3.0.0", "eslint": "^8.53.0", "http-status": "^1.7.3", "jest": "^29.6.4", diff --git a/src/__fixtures__/dynamo.fixture.ts b/src/__fixtures__/dynamo.fixture.ts new file mode 100644 index 0000000..e818946 --- /dev/null +++ b/src/__fixtures__/dynamo.fixture.ts @@ -0,0 +1,121 @@ +import { + BatchWriteCommandInput, + BatchWriteCommandOutput, + DeleteCommandInput, + DeleteCommandOutput, + GetCommandInput, + GetCommandOutput, + PutCommandInput, + PutCommandOutput, + QueryCommandInput, + QueryCommandOutput, + ScanCommandInput, + ScanCommandOutput, + UpdateCommandInput, + UpdateCommandOutput, +} from '@aws-sdk/lib-dynamodb'; + +export const batchWriteCommandInput: BatchWriteCommandInput = { + RequestItems: { + MockTable: [ + { + DeleteRequest: { + Key: { + pk: 1, + }, + }, + }, + ], + }, +}; + +export const batchWriteCommandOutput: BatchWriteCommandOutput = { + $metadata: {}, +}; + +export const deleteCommandInput: DeleteCommandInput = { + TableName: 'MockTable', + Key: { + pk: 1, + }, +}; + +export const deleteCommandOutput: DeleteCommandOutput = { + $metadata: {}, +}; + +export const getCommandInput: GetCommandInput = { + TableName: 'MockTable', + Key: { + pk: 1, + }, +}; + +export const getCommandOutput: GetCommandOutput = { + Item: { + pk: 1, + }, + $metadata: {}, +}; + +export const putCommandInput: PutCommandInput = { + TableName: 'MockTable', + Item: { + pk: 1, + }, +}; + +export const putCommandOutput: PutCommandOutput = { + $metadata: {}, +}; + +export const queryCommandInput: QueryCommandInput = { + TableName: 'MockTable', + KeyConditionExpression: 'pk = :pKey', + ExpressionAttributeValues: { + ':pKey': 1, + }, +}; + +export const queryCommandOutput: QueryCommandOutput = { + Items: [ + { + pk: 1, + }, + ], + $metadata: {}, +}; + +export const scanCommandInput: ScanCommandInput = { + TableName: 'MockTable', +}; + +export const scanCommandOutput: ScanCommandOutput = { + Items: [ + { + pk: 1, + }, + ], + $metadata: {}, +}; + +export const updateCommandInput: UpdateCommandInput = { + TableName: 'MockTable', + Key: { + pk: 1, + }, + UpdateExpression: 'SET attribute = :value', + ConditionExpression: 'pk = :pKey', + ExpressionAttributeValues: { + ':pKey': 1, + }, + ReturnValues: 'ALL_NEW', +}; + +export const updateCommandOutput: UpdateCommandOutput = { + Attributes: { + pk: 1, + attribute: 'value', + }, + $metadata: {}, +}; diff --git a/src/services/__tests__/dynamo.service.test.ts b/src/services/__tests__/dynamo.service.test.ts new file mode 100644 index 0000000..f30c832 --- /dev/null +++ b/src/services/__tests__/dynamo.service.test.ts @@ -0,0 +1,125 @@ +import { + BatchWriteCommand, + DeleteCommand, + DynamoDBDocumentClient, + GetCommand, + PutCommand, + QueryCommand, + ScanCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; + +import DynamoService from '../dynamo.service'; +import { + batchWriteCommandInput, + batchWriteCommandOutput, + deleteCommandInput, + deleteCommandOutput, + getCommandInput, + getCommandOutput, + putCommandInput, + putCommandOutput, + queryCommandInput, + queryCommandOutput, + scanCommandInput, + scanCommandOutput, + updateCommandInput, + updateCommandOutput, +} from '../../__fixtures__/dynamo.fixture'; + +describe('DynamoService', () => { + it('should be defined', () => { + expect(DynamoService.batchWriteItems).toBeDefined(); + expect(DynamoService.deleteItem).toBeDefined(); + expect(DynamoService.getItem).toBeDefined(); + expect(DynamoService.putItem).toBeDefined(); + expect(DynamoService.queryItems).toBeDefined(); + expect(DynamoService.updateItem).toBeDefined(); + }); + + it('should send BatchWriteCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(BatchWriteCommand).resolves(batchWriteCommandOutput); + + const output = await DynamoService.batchWriteItems(batchWriteCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(batchWriteCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(BatchWriteCommand, batchWriteCommandInput); + }); + + it('should send DeleteCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(DeleteCommand).resolves(deleteCommandOutput); + + const output = await DynamoService.deleteItem(deleteCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(deleteCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(DeleteCommand, deleteCommandInput); + }); + + it('should send GetCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(GetCommand).resolves(getCommandOutput); + + const output = await DynamoService.getItem(getCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(getCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(GetCommand, getCommandInput); + }); + + it('should send PutCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(PutCommand).resolves(putCommandOutput); + + const output = await DynamoService.putItem(putCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(putCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, putCommandInput); + }); + + it('should send QueryCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(QueryCommand).resolves(queryCommandOutput); + + const output = await DynamoService.queryItems(queryCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(queryCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, queryCommandInput); + }); + + it('should send ScanCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(ScanCommand).resolves(scanCommandOutput); + + const output = await DynamoService.scanItems(scanCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(scanCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(ScanCommand, scanCommandInput); + }); + + it('should send UpdateCommand', async () => { + const ddbMock = mockClient(DynamoDBDocumentClient); + + ddbMock.on(UpdateCommand).resolves(updateCommandOutput); + + const output = await DynamoService.updateItem(updateCommandInput); + + expect(output).toBeDefined(); + expect(output).toEqual(updateCommandOutput); + expect(ddbMock).toHaveReceivedCommandWith(UpdateCommand, updateCommandInput); + }); +});