diff --git a/packages/idempotency/README.md b/packages/idempotency/README.md index 87bf3e18b8..63833d664d 100644 --- a/packages/idempotency/README.md +++ b/packages/idempotency/README.md @@ -8,15 +8,33 @@ Powertools for AWS Lambda (TypeScript) is a developer toolkit to implement Serverless [best practices and increase developer velocity](https://docs.powertools.aws.dev/lambda-typescript/latest/#features). +You can use the package in both TypeScript and JavaScript code bases. + +- [Intro](#intro) +- [Key features](#key-features) +- [Usage](#usage) + - [Function wrapper](#function-wrapper) + - [Middy middleware](#middy-middleware) + - [DynamoDB persistence layer](#dynamodb-persistence-layer) +- [Contribute](#contribute) +- [Roadmap](#roadmap) +- [Connect](#connect) +- [How to support Powertools for AWS Lambda (TypeScript)?](#how-to-support-powertools-for-aws-lambda-typescript) + - [Becoming a reference customer](#becoming-a-reference-customer) + - [Sharing your work](#sharing-your-work) + - [Using Lambda Layer](#using-lambda-layer) +- [Credits](#credits) +- [License](#license) + ## Intro This package provides a utility to implement idempotency in your Lambda functions. -You can either use it as a decorator on your Lambda handler or as a wrapper on any other function. -If you use middy, we also provide a middleware to make your Lambda handler idempotent. -The current implementation provides a persistance layer for Amazon DynamoDB, which offers a variety of configuration options. -You can also bring your own persistance layer by implementing the `IdempotencyPersistanceLayer` interface. +You can either use it to wrapp a function, or as Middy middleware to make your AWS Lambda handler idempotent. + +The current implementation provides a persistence layer for Amazon DynamoDB, which offers a variety of configuration options. You can also bring your own persistence layer by extending the `BasePersistenceLayer` class. ## Key features + * Prevent Lambda handler from executing more than once on the same event payload during a time window * Ensure Lambda handler returns the same result when called with the same payload * Select a subset of the event as the idempotency key using JMESPath expressions @@ -25,125 +43,99 @@ You can also bring your own persistance layer by implementing the `IdempotencyPe ## Usage -### Decorators -If you use classes to define your Lambda handlers, you can use the decorators to make your handler idempotent or a specific function idempotent. -We offer two decorators: -* `@idempotentLambdaHandler`: makes the handler idempotent. -* `@idempotentFunction`: makes any function within your class idempotent +To get started, install the library by running: -The first can only be applied to the handler function with the specific signature of a Lambda handler. -The second can be applied to any function within your class. In this case you need to pass a `Record` object and provide the `dataKeywordArgument` parameter to specify the name of the argument that contains the data to be used as the idempotency key. -In any of both cases yoiu need to pass the persistance layer where we will store the idempotency information. +```sh +npm install @aws-lambda-powertools/idempotency +``` +Next, review the IAM permissions attached to your AWS Lambda function and make sure you allow the [actions detailed](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/idempotency/#iam-permissions) in the documentation of the utility. ### Function wrapper -A more common approach is to use the function wrapper. -Similar to `@idempotentFunction` decorator you need to pass keyword argument to indicate which part of the payload will be hashed. - -### Middy middleware -// TODO: after e2e tests are implemented - -### DynamoDB peristance layer -To store the idempotency information offer a DynamoDB persistance layer. -This enables you to store the hash key, payload, status for progress and expiration and much more. -You can customise most of the configuration options of the DynamoDB table, i.e the names of the attributes. -See the [API documentation](https://docs.powertools.aws.dev/lambda-typescript/latest/modules/.index.DynamoDBPersistenceLayer.html) for more details. - -## Examples - -### Decorator Lambda handler - -```ts -import { idempotentLambdaHandler } from "@aws-lambda-powertools/idempotency"; -import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/persistance"; -import type { Context } from 'aws-lambda'; - -const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer(); - -class MyLambdaHandler implements LambdaInterface { - @idempotentLambdaHandler({ persistenceStore: dynamoDBPersistenceLayer }) - public async handler(_event: any, _context: Context): Promise { - // your lambda code here - return "Hello World"; - } -} - -const lambdaClass = new MyLambdaHandler(); -export const handler = lambdaClass.handler.bind(lambdaClass); -``` +You can make any function idempotent, and safe to retry, by wrapping it using the `makeFunctionIdempotent` higher-order function. -### Decorator function +The function wrapper takes a reference to the function to be made idempotent as first argument, and an object with options as second argument. ```ts -import { idempotentLambdaHandler } from "@aws-lambda-powertools/idempotency"; -import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/persistance"; -import type { Context } from 'aws-lambda'; +import { makeFunctionIdempotent } from '@aws-lambda-powertools/idempotency'; +import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/persistence'; +import type { Context, SQSEvent, SQSRecord } from 'aws-lambda'; +const persistenceStore = new DynamoDBPersistenceLayer({ + tableName: 'idempotencyTableName', +}); -const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer(); +const processingFunction = async (payload: SQSRecord): Promise => { + // your code goes here here +}; -class MyLambdaHandler implements LambdaInterface { - - public async handler(_event: any, _context: Context): Promise { - for(const record of _event.Records) { - await this.processRecord(record); - } - } - - @idempotentFunction({ persistenceStore: dynamoDBPersistenceLayer, dataKeywordArgument: "payload" }) - public async process(payload: Record): Promise { - // your lambda code here +export const handler = async ( + event: SQSEvent, + _context: Context +): Promise => { + for (const record of event.Records) { + await makeFunctionIdempotent(proccessingFunction, { + dataKeywordArgument: 'transactionId', + persistenceStore, + }); } -} +}; ``` -The `dataKeywordArgument` parameter is optional. If not provided, the whole event will be used as the idempotency key. -Otherwise, you need to specify the string name of the argument that contains the data to be used as the idempotency key. -For example if you have an input like this: +Note that we are specifying a `dataKeywordArgument` option, this tells the Idempotency utility which field(s) will be used as idempotency key. +Check the [docs](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/idempotency/) for more examples. -```json -{ - "transactionId": 1235, - "product": "book", - "quantity": 1, - "price": 10 -} -``` - -You can use `transactionId` as the idempotency key. This will ensure that the same transaction is not processed twice. +### Middy middleware -### Function wrapper +If instead you use Middy, you can use the `makeHandlerIdempotent` middleware. When using the middleware your Lambda handler becomes idempotent. -In case where you don't use classes and decorators you can wrap your function to make it idempotent. +By default, the Idempotency utility will use the full event payload to create an hash and determine if a request is idempotent, and therefore it should not be retried. When dealing with a more elaborate payload, where parts of the payload always change you should use the `IdempotencyConfig` object to instruct the utility to only use a portion of your payload. This is useful when dealing with payloads that contain timestamps or request ids. ```ts -import { makeFunctionIdempotent } from "@aws-lambda-powertools/idempotency"; -import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/persistance"; -import type { Context } from 'aws-lambda'; - +import { IdempotencyConfig } from '@aws-lambda-powertools/idempotency'; +import { makeHandlerIdempotent } from '@aws-lambda-powertools/idempotency/middleware'; +import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/persistence'; +import middy from '@middy/core'; +import type { Context, APIGatewayProxyEvent } from 'aws-lambda'; + +const persistenceStore = new DynamoDBPersistenceLayer({ + tableName: 'idempotencyTableName', +}); +const config = new IdempotencyConfig({ + hashFunction: 'md5', + useLocalCache: false, + expiresAfterSeconds: 3600, + throwOnNoIdempotencyKey: false, + eventKeyJmesPath: 'headers.idempotency-key', +}); -const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer(); -const processingFunction = async (payload: Record): Promise => { - // your lambda code here +const processingFunction = async (payload: SQSRecord): Promise => { + // your code goes here here }; -const processIdempotently = makeFunctionIdempotent(proccessingFunction, { - persistenceStore: dynamoDBPersistenceLayer, - dataKeywordArgument: "transactionId" -}); - -export const handler = async ( - _event: any, - _context: Context -): Promise => { - for (const record of _event.Records) { - await processIdempotently(record); +export const handler = middy( + async (event: APIGatewayProxyEvent, _context: Context): Promise => { + // your code goes here here } -}; +).use( + makeHandlerIdempotent({ + config, + persistenceStore, + }) +); ``` +Check the [docs](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/idempotency/) for more examples. + +### DynamoDB persistence layer + +You can use a DynamoDB Table to store the idempotency information. This enables you to keep track of the hash key, payload, status for progress, expiration, and much more. + +You can customize most of the configuration options of the table, i.e the names of the attributes. +See the [API documentation](https://docs.powertools.aws.dev/lambda/typescript/latest/api/types/_aws_lambda_powertools_idempotency.types.DynamoDBPersistenceOptions.html) for more details. + ## Contribute If you are interested in contributing to this project, please refer to our [Contributing Guidelines](https://github.com/aws-powertools/powertools-lambda-typescript/blob/main/CONTRIBUTING.md). @@ -173,6 +165,7 @@ The following companies, among others, use Powertools: * [Bailey Nelson](https://www.baileynelson.com.au) * [Perfect Post](https://www.perfectpost.fr) * [Sennder](https://sennder.com/) +* [Certible](https://www.certible.com/) ### Sharing your work diff --git a/packages/idempotency/src/idempotentDecorator.ts b/packages/idempotency/src/idempotentDecorator.ts deleted file mode 100644 index 9b97f89bbb..0000000000 --- a/packages/idempotency/src/idempotentDecorator.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - GenericTempRecord, - IdempotencyFunctionOptions, - IdempotencyLambdaHandlerOptions, -} from './types'; -import { IdempotencyHandler } from './IdempotencyHandler'; -import { IdempotencyConfig } from './IdempotencyConfig'; -import { Context } from 'aws-lambda'; - -const isContext = (arg: unknown): arg is Context => { - return ( - arg !== undefined && - arg !== null && - typeof arg === 'object' && - 'getRemainingTimeInMillis' in arg - ); -}; - -/** - * use this function to narrow the type of options between IdempotencyHandlerOptions and IdempotencyFunctionOptions - * @param options - */ -const isFunctionOption = ( - options: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions -): boolean => - (options as IdempotencyFunctionOptions).dataKeywordArgument !== undefined; - -const idempotent = function ( - options: IdempotencyLambdaHandlerOptions | IdempotencyFunctionOptions -): ( - target: unknown, - propertyKey: string, - descriptor: PropertyDescriptor -) => PropertyDescriptor { - return function ( - _target: unknown, - _propertyKey: string, - descriptor: PropertyDescriptor - ) { - const childFunction = descriptor.value; - descriptor.value = function ( - record: GenericTempRecord, - ...args: unknown[] - ) { - const functionPayloadtoBeHashed = isFunctionOption(options) - ? record[(options as IdempotencyFunctionOptions).dataKeywordArgument] - : record; - const idempotencyConfig = options.config - ? options.config - : new IdempotencyConfig({}); - const context = args[0]; - if (isContext(context)) { - idempotencyConfig.registerLambdaContext(context); - } - const idempotencyHandler = new IdempotencyHandler({ - functionToMakeIdempotent: childFunction, - functionPayloadToBeHashed: functionPayloadtoBeHashed, - persistenceStore: options.persistenceStore, - idempotencyConfig: idempotencyConfig, - fullFunctionPayload: record, - }); - - return idempotencyHandler.handle(); - }; - - return descriptor; - }; -}; - -/** - * Use this decorator to make your lambda handler itempotent. - * You need to provide a peristance layer to store the idempotency information. - * At the moment we only support `DynamodbPersistenceLayer`. - * > **Note**: - * > decorators are an exeperimental feature in typescript and may change in the future. - * > To enable decoratopr support in your project, you need to enable the `experimentalDecorators` compiler option in your tsconfig.json file. - * @example - * ```ts - * import { - * DynamoDBPersistenceLayer, - * idempotentLambdaHandler - * } from '@aws-lambda-powertools/idempotency' - * - * class MyLambdaFunction { - * @idempotentLambdaHandler({ persistenceStore: new DynamoDBPersistenceLayer() }) - * async handler(event: any, context: any) { - * return "Hello World"; - * } - * } - * export myLambdaHandler new MyLambdaFunction(); - * export const handler = myLambdaHandler.handler.bind(myLambdaHandler); - * ``` - * @see {@link DynamoDBPersistenceLayer} - * @see https://www.typescriptlang.org/docs/handbook/decorators.html - */ -const idempotentLambdaHandler = function ( - options: IdempotencyLambdaHandlerOptions -): ( - target: unknown, - propertyKey: string, - descriptor: PropertyDescriptor -) => PropertyDescriptor { - return idempotent(options); -}; -/** - * Use this decorator to make any class function idempotent. - * Similar to the `idempotentLambdaHandler` decorator, you need to provide a persistence layer to store the idempotency information. - * @example - * ```ts - * import { - * DynamoDBPersistenceLayer, - * idempotentFunction - * } from '@aws-lambda-powertools/idempotency' - * - * class MyClass { - * - * public async handler(_event: any, _context: any) { - * for(const record of _event.records){ - * await this.process(record); - * } - * } - * - * @idempotentFunction({ persistenceStore: new DynamoDBPersistenceLayer() }) - * public async process(record: Record PropertyDescriptor { - return idempotent(options); -}; - -export { idempotentLambdaHandler, idempotentFunction }; diff --git a/packages/idempotency/src/index.ts b/packages/idempotency/src/index.ts index a07f3c5017..bc5289a9ed 100644 --- a/packages/idempotency/src/index.ts +++ b/packages/idempotency/src/index.ts @@ -1,4 +1,3 @@ export * from './errors'; export * from './IdempotencyConfig'; -export * from './idempotentDecorator'; export * from './makeFunctionIdempotent'; diff --git a/packages/idempotency/src/persistence/DynamoDBPersistenceLayer.ts b/packages/idempotency/src/persistence/DynamoDBPersistenceLayer.ts index 78d85690f7..57b76bb596 100644 --- a/packages/idempotency/src/persistence/DynamoDBPersistenceLayer.ts +++ b/packages/idempotency/src/persistence/DynamoDBPersistenceLayer.ts @@ -3,7 +3,7 @@ import { IdempotencyItemNotFoundError, } from '../errors'; import { IdempotencyRecordStatus } from '../types'; -import type { DynamoPersistenceOptions } from '../types'; +import type { DynamoDBPersistenceOptions } from '../types'; import { DynamoDBClient, DynamoDBClientConfig, @@ -40,7 +40,7 @@ class DynamoDBPersistenceLayer extends BasePersistenceLayer { private tableName: string; private validationKeyAttr: string; - public constructor(config: DynamoPersistenceOptions) { + public constructor(config: DynamoDBPersistenceOptions) { super(); this.tableName = config.tableName; @@ -261,4 +261,3 @@ class DynamoDBPersistenceLayer extends BasePersistenceLayer { } export { DynamoDBPersistenceLayer }; -export type { DynamoPersistenceOptions as DynamoDBPersistenceLayerOptions }; diff --git a/packages/idempotency/src/persistence/IdempotencyRecord.ts b/packages/idempotency/src/persistence/IdempotencyRecord.ts index cec0111270..98ec57da21 100644 --- a/packages/idempotency/src/persistence/IdempotencyRecord.ts +++ b/packages/idempotency/src/persistence/IdempotencyRecord.ts @@ -4,7 +4,7 @@ import { IdempotencyInvalidStatusError } from '../errors'; /** * Class representing an idempotency record. - * The properties of this class will be reflected in the persistance layer. + * The properties of this class will be reflected in the persistence layer. */ class IdempotencyRecord { /** diff --git a/packages/idempotency/src/types/DynamoDBPersistence.ts b/packages/idempotency/src/types/DynamoDBPersistence.ts index 65b1980510..7a1284d692 100644 --- a/packages/idempotency/src/types/DynamoDBPersistence.ts +++ b/packages/idempotency/src/types/DynamoDBPersistence.ts @@ -17,7 +17,7 @@ import type { * @property {string} [sortKeyAttr] - The DynamoDB table sort key attribute name, use only when table has one. Defaults to undefined. * @property {string} [staticPkValue] - The DynamoDB table static partition key value, use only with sortKeyAttr. Defaults to `idempotency#{LAMBDA_FUNCTION_NAME}`. */ -interface DynamoPersistenceOptionsBaseInterface { +interface DynamoDBPersistenceOptionsBase { tableName: string; keyAttr?: string; expiryAttr?: string; @@ -30,29 +30,29 @@ interface DynamoPersistenceOptionsBaseInterface { } /** - * Interface for DynamoPersistenceOptions with clientConfig property. + * Interface for DynamoDBPersistenceOptions with clientConfig property. * * @interface - * @extends DynamoPersistenceOptionsBaseInterface + * @extends DynamoDBPersistenceOptionsBase * @property {DynamoDBClientConfig} [clientConfig] - Optional configuration to pass during client initialization, e.g. AWS region. * @property {never} [awsSdkV3Client] - This property should never be passed. */ -interface DynamoPersistenceOptionsWithClientConfig - extends DynamoPersistenceOptionsBaseInterface { +interface DynamoDBPersistenceOptionsWithClientConfig + extends DynamoDBPersistenceOptionsBase { awsSdkV3Client?: never; clientConfig?: DynamoDBClientConfig; } /** - * Interface for DynamoPersistenceOptions with awsSdkV3Client property. + * Interface for DynamoDBPersistenceOptions with awsSdkV3Client property. * * @interface - * @extends DynamoPersistenceOptionsBaseInterface + * @extends DynamoDBPersistenceOptionsBase * @property {DynamoDBClient} [awsSdkV3Client] - Optional AWS SDK v3 client to pass during AppConfigProvider class instantiation * @property {never} [clientConfig] - This property should never be passed. */ -interface DynamoPersistenceOptionsWithClientInstance - extends DynamoPersistenceOptionsBaseInterface { +interface DynamoDBPersistenceOptionsWithClientInstance + extends DynamoDBPersistenceOptionsBase { awsSdkV3Client?: DynamoDBClient; clientConfig?: never; } @@ -60,7 +60,9 @@ interface DynamoPersistenceOptionsWithClientInstance /** * Options for the {@link DynamoDBPersistenceLayer} class constructor. * - * @type DynamoPersistenceOptions + * @see {@link DynamoDBPersistenceOptionsBase}, {@link DynamoDBPersistenceOptionsWithClientConfig}, and {@link DynamoDBPersistenceOptionsWithClientInstance} for full list of properties. + * + * @type DynamoDBPersistenceOptions * @property {string} tableName - The DynamoDB table name. * @property {string} [keyAttr] - The DynamoDB table key attribute name. Defaults to 'id'. * @property {string} [expiryAttr] - The DynamoDB table expiry attribute name. Defaults to 'expiration'. @@ -73,8 +75,13 @@ interface DynamoPersistenceOptionsWithClientInstance * @property {DynamoDBClientConfig} [clientConfig] - Optional configuration to pass during client initialization, e.g. AWS region. Mutually exclusive with awsSdkV3Client. * @property {DynamoDBClient} [awsSdkV3Client] - Optional AWS SDK v3 client to pass during DynamoDBProvider class instantiation. Mutually exclusive with clientConfig. */ -type DynamoPersistenceOptions = - | DynamoPersistenceOptionsWithClientConfig - | DynamoPersistenceOptionsWithClientInstance; +type DynamoDBPersistenceOptions = + | DynamoDBPersistenceOptionsWithClientConfig + | DynamoDBPersistenceOptionsWithClientInstance; -export type { DynamoPersistenceOptions }; +export type { + DynamoDBPersistenceOptionsBase, + DynamoDBPersistenceOptionsWithClientConfig, + DynamoDBPersistenceOptionsWithClientInstance, + DynamoDBPersistenceOptions, +}; diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts deleted file mode 100644 index 9d6d0b4471..0000000000 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.FunctionCode.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Context } from 'aws-lambda'; -import { LambdaInterface } from '@aws-lambda-powertools/commons'; -import { idempotentFunction, idempotentLambdaHandler } from '../../src'; -import { Logger } from '../../../logger'; -import { DynamoDBPersistenceLayer } from '../../src/persistence/DynamoDBPersistenceLayer'; -import { IdempotencyConfig } from '../../src/'; - -const IDEMPOTENCY_TABLE_NAME = - process.env.IDEMPOTENCY_TABLE_NAME || 'table_name'; -const dynamoDBPersistenceLayer = new DynamoDBPersistenceLayer({ - tableName: IDEMPOTENCY_TABLE_NAME, -}); - -const ddbPersistenceLayerCustomized = new DynamoDBPersistenceLayer({ - tableName: IDEMPOTENCY_TABLE_NAME, - dataAttr: 'dataattr', - keyAttr: 'customId', - expiryAttr: 'expiryattr', - statusAttr: 'statusattr', - inProgressExpiryAttr: 'inprogressexpiryattr', - staticPkValue: 'staticpkvalue', - validationKeyAttr: 'validationkeyattr', -}); - -interface TestEvent { - [key: string]: string; -} - -interface EventRecords { - records: Record[]; -} - -class DefaultLambda implements LambdaInterface { - @idempotentLambdaHandler({ persistenceStore: dynamoDBPersistenceLayer }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handler(_event: Record, _context: Context): Promise { - logger.info(`Got test event: ${JSON.stringify(_event)}`); - // sleep to enforce error with parallel execution - await new Promise((resolve) => setTimeout(resolve, 3000)); - - return 'Hello World'; - } - - @idempotentLambdaHandler({ persistenceStore: ddbPersistenceLayerCustomized }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handlerCustomized( - _event: TestEvent, - _context: Context - ): Promise { - logger.info(`Got test event customized: ${JSON.stringify(_event)}`); - // sleep for 5 seconds - - return 'Hello World Customized'; - } - - @idempotentLambdaHandler({ persistenceStore: dynamoDBPersistenceLayer }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handlerFails( - _event: TestEvent, - _context: Context - ): Promise { - logger.info(`Got test event: ${JSON.stringify(_event)}`); - - throw new Error('Failed'); - } - - @idempotentLambdaHandler({ - persistenceStore: dynamoDBPersistenceLayer, - config: new IdempotencyConfig({ - eventKeyJmesPath: 'idempotencyKey', - throwOnNoIdempotencyKey: false, - }), - }) - public async handlerWithOptionalIdempoitencyKey( - _event: TestEvent, - _context: Context - ): Promise { - logger.info(`Got test event: ${JSON.stringify(_event)}`); - - return 'This should not be stored in DynamoDB'; - } -} - -const defaultLambda = new DefaultLambda(); -export const handler = defaultLambda.handler.bind(defaultLambda); -export const handlerCustomized = - defaultLambda.handlerCustomized.bind(defaultLambda); -export const handlerFails = defaultLambda.handlerFails.bind(defaultLambda); - -export const handlerWithOptionalIdempoitencyKey = - defaultLambda.handlerWithOptionalIdempoitencyKey.bind(defaultLambda); - -const logger = new Logger(); - -class LambdaWithKeywordArgument implements LambdaInterface { - public async handler( - _event: EventRecords, - _context: Context - ): Promise { - logger.info(`Got test event: ${JSON.stringify(_event)}`); - for (const record of _event.records) { - logger.info(`Processing event: ${JSON.stringify(record)}`); - await this.process(record); - } - - return 'Hello World Keyword Argument'; - } - - @idempotentFunction({ - persistenceStore: dynamoDBPersistenceLayer, - dataKeywordArgument: 'foo', - }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async process(record: Record): string { - logger.info(`Processing inside: ${JSON.stringify(record)}`); - - return 'idempotent result: ' + record.foo; - } -} - -const lambdaWithKeywordArg = new LambdaWithKeywordArgument(); -export const handlerWithKeywordArgument = - lambdaWithKeywordArg.handler.bind(lambdaWithKeywordArg); diff --git a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts b/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts deleted file mode 100644 index 9b2fb5c26a..0000000000 --- a/packages/idempotency/tests/e2e/idempotencyDecorator.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Test idempotency decorator - * - * @group e2e/idempotency - */ -import { v4 } from 'uuid'; -import { App, Stack } from 'aws-cdk-lib'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { - generateUniqueName, - invokeFunction, - isValidRuntimeKey, -} from '../../../commons/tests/utils/e2eUtils'; -import { - RESOURCE_NAME_PREFIX, - SETUP_TIMEOUT, - TEARDOWN_TIMEOUT, - TEST_CASE_TIMEOUT, -} from './constants'; -import { - deployStack, - destroyStack, -} from '../../../commons/tests/utils/cdk-cli'; -import { LEVEL } from '../../../commons/tests/utils/InvocationLogs'; -import { GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; -import { createHash } from 'node:crypto'; -import { createIdempotencyResources } from '../helpers/idempotencyUtils'; - -const runtime: string = process.env.RUNTIME || 'nodejs18x'; - -if (!isValidRuntimeKey(runtime)) { - throw new Error(`Invalid runtime key value: ${runtime}`); -} - -const uuid = v4(); -const stackName = generateUniqueName( - RESOURCE_NAME_PREFIX, - uuid, - runtime, - 'Idempotency' -); -const decoratorFunctionFile = 'idempotencyDecorator.test.FunctionCode.ts'; - -const app = new App(); - -const ddb = new DynamoDBClient({ region: 'eu-west-1' }); -const stack = new Stack(app, stackName); - -const functionNameDefault = generateUniqueName( - RESOURCE_NAME_PREFIX, - uuid, - runtime, - 'default' -); -const ddbTableNameDefault = stackName + '-default-table'; -createIdempotencyResources( - stack, - runtime, - ddbTableNameDefault, - decoratorFunctionFile, - functionNameDefault, - 'handler' -); - -const functionNameCustom = generateUniqueName( - RESOURCE_NAME_PREFIX, - uuid, - runtime, - 'custom' -); -const ddbTableNameCustom = stackName + '-custom-table'; -createIdempotencyResources( - stack, - runtime, - ddbTableNameCustom, - decoratorFunctionFile, - functionNameCustom, - 'handlerCustomized', - 'customId' -); - -const functionNameKeywordArg = generateUniqueName( - RESOURCE_NAME_PREFIX, - uuid, - runtime, - 'keywordarg' -); -const ddbTableNameKeywordArg = stackName + '-keywordarg-table'; -createIdempotencyResources( - stack, - runtime, - ddbTableNameKeywordArg, - decoratorFunctionFile, - functionNameKeywordArg, - 'handlerWithKeywordArgument' -); - -const functionNameFails = generateUniqueName( - RESOURCE_NAME_PREFIX, - uuid, - runtime, - 'fails' -); -const ddbTableNameFails = stackName + '-fails-table'; -createIdempotencyResources( - stack, - runtime, - ddbTableNameFails, - decoratorFunctionFile, - functionNameFails, - 'handlerFails' -); - -const functionNameOptionalIdempotencyKey = generateUniqueName( - RESOURCE_NAME_PREFIX, - uuid, - runtime, - 'optionalIdempotencyKey' -); -const ddbTableNameOptionalIdempotencyKey = - stackName + '-optional-idempotencyKey-table'; -createIdempotencyResources( - stack, - runtime, - ddbTableNameOptionalIdempotencyKey, - decoratorFunctionFile, - functionNameOptionalIdempotencyKey, - 'handlerWithOptionalIdempoitencyKey' -); -describe('Idempotency e2e test decorator, default settings', () => { - beforeAll(async () => { - await deployStack(app, stack); - }, SETUP_TIMEOUT); - - test( - 'when called twice, it returns the same value without calling the inner function', - async () => { - const payload = { foo: 'baz' }; - const payloadHash = createHash('md5') - .update(JSON.stringify(payload)) - .digest('base64'); - - const invocationLogsSequential = await invokeFunction( - functionNameDefault, - 2, - 'SEQUENTIAL', - payload, - false - ); - // create dynamodb client to query the table and check the value - const result = await ddb.send( - new GetCommand({ - TableName: ddbTableNameDefault, - Key: { id: `${functionNameDefault}#${payloadHash}` }, - }) - ); - expect(result?.Item?.data).toEqual('Hello World'); - expect(result?.Item?.status).toEqual('COMPLETED'); - expect(result?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - // we log events inside the handler, so the 2nd invocation should not log anything - expect( - invocationLogsSequential[0].getFunctionLogs().toString() - ).toContain('Got test event'); - expect( - invocationLogsSequential[1].getFunctionLogs().toString() - ).not.toContain('Got test event'); - }, - TEST_CASE_TIMEOUT - ); - - test( - 'when called twice in parallel, it trows an error', - async () => { - const payload = { id: '123' }; - const payloadHash = createHash('md5') - .update(JSON.stringify(payload)) - .digest('base64'); - const invocationLogs = await invokeFunction( - functionNameDefault, - 2, - 'PARALLEL', - payload, - false - ); - - const result = await ddb.send( - new GetCommand({ - TableName: ddbTableNameDefault, - Key: { id: `${functionNameDefault}#${payloadHash}` }, - }) - ); - expect(result?.Item?.data).toEqual('Hello World'); - expect(result?.Item?.status).toEqual('COMPLETED'); - expect(result?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - expect( - invocationLogs[0].getFunctionLogs(LEVEL.ERROR).toString() - ).toContain( - 'There is already an execution in progress with idempotency key' - ); - }, - TEST_CASE_TIMEOUT - ); - - test( - 'when called with customized idempotency decorator, it creates ddb entry with custom attributes', - async () => { - const payload = { foo: 'baz' }; - const payloadHash = createHash('md5') - .update(JSON.stringify(payload)) - .digest('base64'); - - const invocationLogsCustmozed = await invokeFunction( - functionNameCustom, - 1, - 'PARALLEL', - payload, - false - ); - const result = await ddb.send( - new GetCommand({ - TableName: ddbTableNameCustom, - Key: { customId: `${functionNameCustom}#${payloadHash}` }, - }) - ); - expect(result?.Item?.dataattr).toEqual('Hello World Customized'); - expect(result?.Item?.statusattr).toEqual('COMPLETED'); - expect(result?.Item?.expiryattr).toBeGreaterThan(Date.now() / 1000); - expect(invocationLogsCustmozed[0].getFunctionLogs().toString()).toContain( - 'Got test event customized' - ); - }, - TEST_CASE_TIMEOUT - ); - - test( - 'when called with a function that fails, it creates ddb entry with error status', - async () => { - const payload = { foo: 'baz' }; - const payloadHash = createHash('md5') - .update(JSON.stringify(payload)) - .digest('base64'); - - await invokeFunction(functionNameFails, 1, 'PARALLEL', payload, false); - const result = await ddb.send( - new GetCommand({ - TableName: ddbTableNameFails, - Key: { id: `${functionNameFails}#${payloadHash}` }, - }) - ); - console.log(result); - expect(result?.Item).toBeUndefined(); - }, - TEST_CASE_TIMEOUT - ); - - test( - 'when called with a function that has keyword argument, it creates for every entry of keyword argument', - async () => { - const payloadArray = { - records: [ - { id: 1, foo: 'bar' }, - { id: 2, foo: 'baq' }, - { id: 3, foo: 'bar' }, - ], - }; - const payloadHashFirst = createHash('md5') - .update('"bar"') - .digest('base64'); - - await invokeFunction( - functionNameKeywordArg, - 2, - 'SEQUENTIAL', - payloadArray, - false - ); - const resultFirst = await ddb.send( - new GetCommand({ - TableName: ddbTableNameKeywordArg, - Key: { id: `${functionNameKeywordArg}#${payloadHashFirst}` }, - }) - ); - console.log(resultFirst); - expect(resultFirst?.Item?.data).toEqual('idempotent result: bar'); - expect(resultFirst?.Item?.status).toEqual('COMPLETED'); - expect(resultFirst?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - - const payloadHashSecond = createHash('md5') - .update('"baq"') - .digest('base64'); - const resultSecond = await ddb.send( - new GetCommand({ - TableName: ddbTableNameKeywordArg, - Key: { id: `${functionNameKeywordArg}#${payloadHashSecond}` }, - }) - ); - console.log(resultSecond); - expect(resultSecond?.Item?.data).toEqual('idempotent result: baq'); - expect(resultSecond?.Item?.status).toEqual('COMPLETED'); - expect(resultSecond?.Item?.expiration).toBeGreaterThan(Date.now() / 1000); - }, - TEST_CASE_TIMEOUT - ); - - test( - 'when called with a function with optional idempotency key and thorwOnNoIdempotencyKey is false, it does not create ddb entry', - async () => { - const payload = { foo: 'baz' }; // we set eventKeyJmesPath: 'idempotencyKey' in the idempotency configuration - await invokeFunction( - functionNameOptionalIdempotencyKey, - 2, - 'PARALLEL', - payload, - false - ); - const result = await ddb.send( - new ScanCommand({ - TableName: ddbTableNameOptionalIdempotencyKey, - }) - ); - expect(result?.Items).toEqual([]); - }, - TEST_CASE_TIMEOUT - ); - - afterAll(async () => { - if (!process.env.DISABLE_TEARDOWN) { - await destroyStack(app, stack); - } - }, TEARDOWN_TIMEOUT); -}); diff --git a/packages/idempotency/tests/unit/idempotentDecorator.test.ts b/packages/idempotency/tests/unit/idempotentDecorator.test.ts deleted file mode 100644 index e232ac595b..0000000000 --- a/packages/idempotency/tests/unit/idempotentDecorator.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Test Function Wrapper - * - * @group unit/idempotency/decorator - */ - -import { BasePersistenceLayer, IdempotencyRecord } from '../../src/persistence'; -import { idempotentFunction, idempotentLambdaHandler } from '../../src/'; -import type { IdempotencyRecordOptions } from '../../src/types'; -import { IdempotencyRecordStatus } from '../../src/types'; -import { - IdempotencyAlreadyInProgressError, - IdempotencyInconsistentStateError, - IdempotencyItemAlreadyExistsError, - IdempotencyPersistenceLayerError, -} from '../../src/errors'; -import { IdempotencyConfig } from '../../src'; -import { Context } from 'aws-lambda'; -import { helloworldContext } from '@aws-lambda-powertools/commons/lib/samples/resources/contexts'; - -const mockSaveInProgress = jest - .spyOn(BasePersistenceLayer.prototype, 'saveInProgress') - .mockImplementation(); -const mockSaveSuccess = jest - .spyOn(BasePersistenceLayer.prototype, 'saveSuccess') - .mockImplementation(); -const mockGetRecord = jest - .spyOn(BasePersistenceLayer.prototype, 'getRecord') - .mockImplementation(); - -const dummyContext = helloworldContext; - -const mockConfig: IdempotencyConfig = new IdempotencyConfig({}); - -class PersistenceLayerTestClass extends BasePersistenceLayer { - protected _deleteRecord = jest.fn(); - protected _getRecord = jest.fn(); - protected _putRecord = jest.fn(); - protected _updateRecord = jest.fn(); -} - -const functionalityToDecorate = jest.fn(); - -class TestinClassWithLambdaHandler { - @idempotentLambdaHandler({ - persistenceStore: new PersistenceLayerTestClass(), - }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public testing(record: Record, context: Context): string { - functionalityToDecorate(record); - - return 'Hi'; - } -} - -class TestingClassWithFunctionDecorator { - public handler(record: Record, context: Context): string { - mockConfig.registerLambdaContext(context); - - return this.proccessRecord(record); - } - - @idempotentFunction({ - persistenceStore: new PersistenceLayerTestClass(), - dataKeywordArgument: 'testingKey', - config: mockConfig, - }) - public proccessRecord(record: Record): string { - functionalityToDecorate(record); - - return 'Processed Record'; - } -} - -describe('Given a class with a function to decorate', (classWithLambdaHandler = new TestinClassWithLambdaHandler(), classWithFunctionDecorator = new TestingClassWithFunctionDecorator()) => { - const keyValueToBeSaved = 'thisWillBeSaved'; - const inputRecord = { - testingKey: keyValueToBeSaved, - otherKey: 'thisWillNot', - }; - beforeEach(() => jest.clearAllMocks()); - - describe('When wrapping a function with no previous executions', () => { - beforeEach(async () => { - await classWithFunctionDecorator.handler(inputRecord, dummyContext); - }); - - test('Then it will save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith( - keyValueToBeSaved, - dummyContext.getRemainingTimeInMillis() - ); - }); - - test('Then it will call the function that was decorated', () => { - expect(functionalityToDecorate).toBeCalledWith(inputRecord); - }); - - test('Then it will save the record to COMPLETED with function return value', () => { - expect(mockSaveSuccess).toBeCalledWith( - keyValueToBeSaved, - 'Processed Record' - ); - }); - }); - describe('When wrapping a function with no previous executions', () => { - beforeEach(async () => { - await classWithLambdaHandler.testing(inputRecord, dummyContext); - }); - - test('Then it will save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith( - inputRecord, - dummyContext.getRemainingTimeInMillis() - ); - }); - - test('Then it will call the function that was decorated', () => { - expect(functionalityToDecorate).toBeCalledWith(inputRecord); - }); - - test('Then it will save the record to COMPLETED with function return value', () => { - expect(mockSaveSuccess).toBeCalledWith(inputRecord, 'Hi'); - }); - }); - - describe('When decorating a function with previous execution that is INPROGRESS', () => { - let resultingError: Error; - beforeEach(async () => { - mockSaveInProgress.mockRejectedValue( - new IdempotencyItemAlreadyExistsError() - ); - const idempotencyOptions: IdempotencyRecordOptions = { - idempotencyKey: 'key', - status: IdempotencyRecordStatus.INPROGRESS, - }; - mockGetRecord.mockResolvedValue( - new IdempotencyRecord(idempotencyOptions) - ); - try { - await classWithLambdaHandler.testing(inputRecord, dummyContext); - } catch (e) { - resultingError = e as Error; - } - }); - - test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith( - inputRecord, - dummyContext.getRemainingTimeInMillis() - ); - }); - - test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(inputRecord); - }); - - test('Then it will not call the function that was decorated', () => { - expect(functionalityToDecorate).not.toBeCalled(); - }); - - test('Then an IdempotencyAlreadyInProgressError is thrown', () => { - expect(resultingError).toBeInstanceOf(IdempotencyAlreadyInProgressError); - }); - }); - - describe('When decorating a function with previous execution that is EXPIRED', () => { - let resultingError: Error; - beforeEach(async () => { - mockSaveInProgress.mockRejectedValue( - new IdempotencyItemAlreadyExistsError() - ); - const idempotencyOptions: IdempotencyRecordOptions = { - idempotencyKey: 'key', - status: IdempotencyRecordStatus.EXPIRED, - }; - mockGetRecord.mockResolvedValue( - new IdempotencyRecord(idempotencyOptions) - ); - try { - await classWithLambdaHandler.testing(inputRecord, dummyContext); - } catch (e) { - resultingError = e as Error; - } - }); - - test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith( - inputRecord, - dummyContext.getRemainingTimeInMillis() - ); - }); - - test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(inputRecord); - }); - - test('Then it will not call the function that was decorated', () => { - expect(functionalityToDecorate).not.toBeCalled(); - }); - - test('Then an IdempotencyInconsistentStateError is thrown', () => { - expect(resultingError).toBeInstanceOf(IdempotencyInconsistentStateError); - }); - }); - - describe('When wrapping a function with previous execution that is COMPLETED', () => { - beforeEach(async () => { - mockSaveInProgress.mockRejectedValue( - new IdempotencyItemAlreadyExistsError() - ); - const idempotencyOptions: IdempotencyRecordOptions = { - idempotencyKey: 'key', - status: IdempotencyRecordStatus.COMPLETED, - }; - - mockGetRecord.mockResolvedValue( - new IdempotencyRecord(idempotencyOptions) - ); - await classWithLambdaHandler.testing(inputRecord, dummyContext); - }); - - test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith( - inputRecord, - dummyContext.getRemainingTimeInMillis() - ); - }); - - test('Then it will get the previous execution record', () => { - expect(mockGetRecord).toBeCalledWith(inputRecord); - }); - - test('Then it will not call decorated functionality', () => { - expect(functionalityToDecorate).not.toBeCalledWith(inputRecord); - }); - }); - - describe('When wrapping a function with issues saving the record', () => { - class TestinClassWithLambdaHandlerWithConfig { - @idempotentLambdaHandler({ - persistenceStore: new PersistenceLayerTestClass(), - config: new IdempotencyConfig({ lambdaContext: dummyContext }), - }) - public testing(record: Record): string { - functionalityToDecorate(record); - - return 'Hi'; - } - } - - let resultingError: Error; - beforeEach(async () => { - mockSaveInProgress.mockRejectedValue(new Error('RandomError')); - const classWithLambdaHandlerWithConfig = - new TestinClassWithLambdaHandlerWithConfig(); - try { - await classWithLambdaHandlerWithConfig.testing(inputRecord); - } catch (e) { - resultingError = e as Error; - } - }); - - test('Then it will attempt to save the record to INPROGRESS', () => { - expect(mockSaveInProgress).toBeCalledWith( - inputRecord, - dummyContext.getRemainingTimeInMillis() - ); - }); - - test('Then an IdempotencyPersistenceLayerError is thrown', () => { - expect(resultingError).toBeInstanceOf(IdempotencyPersistenceLayerError); - }); - }); -}); diff --git a/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts b/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts index 68810e9a35..204b519dba 100644 --- a/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts +++ b/packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts @@ -9,7 +9,7 @@ import { IdempotencyItemNotFoundError, } from '../../../src/errors'; import { IdempotencyRecord } from '../../../src/persistence'; -import type { DynamoPersistenceOptions } from '../../../src/types'; +import type { DynamoDBPersistenceOptions } from '../../../src/types'; import { IdempotencyRecordStatus } from '../../../src/types'; import { DynamoDBClient, @@ -92,7 +92,7 @@ describe('Class: DynamoDBPersistenceLayer', () => { test('when instantiated with specific options it creates an instance with correct values', () => { // Prepare - const testDynamoDBPersistenceLayerOptions: DynamoPersistenceOptions = { + const testDynamoDBPersistenceLayerOptions: DynamoDBPersistenceOptions = { tableName: dummyTableName, keyAttr: dummyKey, statusAttr: 'someStatusAttr', @@ -129,7 +129,7 @@ describe('Class: DynamoDBPersistenceLayer', () => { test('when instantiated with a sortKeyAttr that has same value of keyAttr, it throws', () => { // Prepare - const testDynamoDBPersistenceLayerOptions: DynamoPersistenceOptions = { + const testDynamoDBPersistenceLayerOptions: DynamoDBPersistenceOptions = { tableName: dummyTableName, keyAttr: dummyKey, sortKeyAttr: dummyKey, diff --git a/packages/idempotency/typedoc.json b/packages/idempotency/typedoc.json index b983e7c7b4..e2ece84d17 100644 --- a/packages/idempotency/typedoc.json +++ b/packages/idempotency/typedoc.json @@ -6,7 +6,8 @@ "./src/index.ts", "./src/types/index.ts", "./src/middleware/index.ts", - "./src/persistence/index.ts" + "./src/persistence/index.ts", + "./src/persistence/DynamoDBPersistenceLayer.ts", ], "readme": "README.md" } \ No newline at end of file