diff --git a/README.md b/README.md index 834357e..b379c2e 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 + +For more detailed information regarding specific components see the [documentation](/docs/DOCS.md). diff --git a/docs/DOCS.md b/docs/DOCS.md new file mode 100644 index 0000000..6fcd8a8 --- /dev/null +++ b/docs/DOCS.md @@ -0,0 +1,12 @@ +:house: [Home](/README.md) + +# @leanstacks/serverless-common Documentation + +The documentation is organized into sections by module. + +## Table of Contents + +1. [Handlers](/docs/utils/MIDDYFY.md) +1. [Configuration](/docs/services/CONFIG.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..b47021b --- /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 +} +``` diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md new file mode 100644 index 0000000..73a5053 --- /dev/null +++ b/docs/services/CONFIG.md @@ -0,0 +1,80 @@ +:house: [Home](/README.md) | :books: [Docs](../DOCS.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 `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 +`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`](/src/services/config.service.ts). + +```ts +// 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. + +```ts +// 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), +}); + +// validate and process custom configuration +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... + +```ts +// some-component.ts +import config from 'my-config'; + +console.log(`The table name is ${config.TABLE_NAME}`); +``` + +## Performance considerations + +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. 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. diff --git a/docs/utils/MIDDYFY.md b/docs/utils/MIDDYFY.md new file mode 100644 index 0000000..e5d0cc6 --- /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 or middleware. + +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! diff --git a/jest.config.ts b/jest.config.ts index 075fb22..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", @@ -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/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/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(), ], 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/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', () => { 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.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({ 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 => { 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'); + }); +}); 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); + }); +}); diff --git a/src/services/dynamo.service.ts b/src/services/dynamo.service.ts index c44012c..d26eca9 100644 --- a/src/services/dynamo.service.ts +++ b/src/services/dynamo.service.ts @@ -41,49 +41,104 @@ const unmarshallOptions = { }; const translateConfig: TranslateConfig = { marshallOptions, unmarshallOptions }; -export class DynamoService { - private static instance: DynamoService; - private client: DynamoDBDocumentClient; +/** + * 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, +); - private constructor() { - this.client = DynamoDBDocumentClient.from( - new DynamoDBClient(dynamoDbClientConfig), - translateConfig, - ); - } +/** + * 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)); +}; - public static getInstance() { - if (!DynamoService.instance) { - DynamoService.instance = new DynamoService(); - } - return DynamoService.instance; - } +/** + * 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)); +}; - batchWrite(input: BatchWriteCommandInput): Promise { - return this.client.send(new BatchWriteCommand(input)); - } +/** + * 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)); +}; + +/** + * 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)); +}; - delete(input: DeleteCommandInput): Promise { - return this.client.send(new DeleteCommand(input)); - } +/** + * 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)); +}; - get(input: GetCommandInput): Promise { - return this.client.send(new GetCommand(input)); - } +/** + * 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)); +}; - put(input: PutCommandInput): Promise { - return this.client.send(new PutCommand(input)); - } +/** + * 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)); +}; - 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'; 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 }),