diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index a689153578..c016ec814e 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -1,3 +1,4 @@ +import type { Handler } from 'aws-lambda'; import type { JSONValue, MiddyLikeRequest, @@ -53,6 +54,12 @@ export class IdempotencyHandler { * Persistence layer used to store the idempotency records. */ readonly #persistenceStore: BasePersistenceLayer; + /** + * The `this` context to be used when calling the function. + * + * When decorating a class method, this will be the instance of the class. + */ + readonly #thisArg?: Handler; public constructor(options: IdempotencyHandlerOptions) { const { @@ -61,11 +68,13 @@ export class IdempotencyHandler { idempotencyConfig, functionArguments, persistenceStore, + thisArg, } = options; this.#functionToMakeIdempotent = functionToMakeIdempotent; this.#functionPayloadToBeHashed = functionPayloadToBeHashed; this.#idempotencyConfig = idempotencyConfig; this.#functionArguments = functionArguments; + this.#thisArg = thisArg; this.#persistenceStore = persistenceStore; @@ -121,7 +130,10 @@ export class IdempotencyHandler { public async getFunctionResult(): Promise> { let result; try { - result = await this.#functionToMakeIdempotent(...this.#functionArguments); + result = await this.#functionToMakeIdempotent.apply( + this.#thisArg, + this.#functionArguments + ); } catch (error) { await this.#deleteInProgressRecord(); throw error; @@ -149,7 +161,10 @@ export class IdempotencyHandler { public async handle(): Promise> { // early return if we should skip idempotency completely if (this.shouldSkipIdempotency()) { - return await this.#functionToMakeIdempotent(...this.#functionArguments); + return await this.#functionToMakeIdempotent.apply( + this.#thisArg, + this.#functionArguments + ); } let e; diff --git a/packages/idempotency/src/idempotencyDecorator.ts b/packages/idempotency/src/idempotencyDecorator.ts index 6c6ff7b2e1..00c10690cb 100644 --- a/packages/idempotency/src/idempotencyDecorator.ts +++ b/packages/idempotency/src/idempotencyDecorator.ts @@ -1,3 +1,4 @@ +import type { Handler } from 'aws-lambda'; import { AnyFunction, ItempotentFunctionOptions, @@ -65,7 +66,10 @@ const idempotent = function ( descriptor: PropertyDescriptor ) { const childFunction = descriptor.value; - descriptor.value = makeIdempotent(childFunction, options); + + descriptor.value = async function (this: Handler, ...args: unknown[]) { + return makeIdempotent(childFunction, options).bind(this)(...args); + }; return descriptor; }; diff --git a/packages/idempotency/src/makeIdempotent.ts b/packages/idempotency/src/makeIdempotent.ts index dafb807e53..ed02a64f44 100644 --- a/packages/idempotency/src/makeIdempotent.ts +++ b/packages/idempotency/src/makeIdempotent.ts @@ -72,16 +72,17 @@ const isOptionsWithDataIndexArgument = ( * * ``` */ -const makeIdempotent = ( +// eslint-disable-next-line func-style +function makeIdempotent( fn: Func, options: ItempotentFunctionOptions> -): ((...args: Parameters) => ReturnType) => { +): (...args: Parameters) => ReturnType { const { persistenceStore, config } = options; const idempotencyConfig = config ? config : new IdempotencyConfig({}); if (!idempotencyConfig.isEnabled()) return fn; - return (...args: Parameters): ReturnType => { + return function (this: Handler, ...args: Parameters): ReturnType { let functionPayloadToBeHashed; if (isFnHandler(fn, args)) { @@ -101,8 +102,9 @@ const makeIdempotent = ( persistenceStore: persistenceStore, functionArguments: args, functionPayloadToBeHashed, + thisArg: this, }).handle() as ReturnType; }; -}; +} export { makeIdempotent }; diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index c8ae467ad4..e1c290e233 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -1,4 +1,4 @@ -import type { Context } from 'aws-lambda'; +import type { Context, Handler } from 'aws-lambda'; import type { BasePersistenceLayer } from '../persistence/BasePersistenceLayer.js'; import type { IdempotencyConfig } from '../IdempotencyConfig.js'; import type { JSONValue } from '@aws-lambda-powertools/commons/types'; @@ -139,6 +139,12 @@ type IdempotencyHandlerOptions = { * Persistence layer used to store the idempotency records. */ persistenceStore: BasePersistenceLayer; + /** + * The `this` context to be used when calling the function. + * + * When decorating a class method, this will be the instance of the class. + */ + thisArg?: Handler; }; /** diff --git a/packages/idempotency/tests/e2e/idempotentDecorator.test.FunctionCode.ts b/packages/idempotency/tests/e2e/idempotentDecorator.test.FunctionCode.ts index 9b6dccbad2..603bff9208 100644 --- a/packages/idempotency/tests/e2e/idempotentDecorator.test.FunctionCode.ts +++ b/packages/idempotency/tests/e2e/idempotentDecorator.test.FunctionCode.ts @@ -25,12 +25,14 @@ const dynamoDBPersistenceLayerCustomized = new DynamoDBPersistenceLayer({ const config = new IdempotencyConfig({}); class DefaultLambda implements LambdaInterface { + private readonly message = 'Got test event:'; + @idempotent({ persistenceStore: dynamoDBPersistenceLayer }) public async handler( _event: Record, _context: Context ): Promise { - logger.info(`Got test event: ${JSON.stringify(_event)}`); + logger.info(`${this.message} ${JSON.stringify(_event)}`); // sleep to enforce error with parallel execution await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/packages/idempotency/tests/unit/idempotencyDecorator.test.ts b/packages/idempotency/tests/unit/idempotencyDecorator.test.ts index ebd8caacd6..db1353b6da 100644 --- a/packages/idempotency/tests/unit/idempotencyDecorator.test.ts +++ b/packages/idempotency/tests/unit/idempotencyDecorator.test.ts @@ -19,6 +19,7 @@ import type { IdempotencyRecordOptions } from '../../src/types/index.js'; import { Context } from 'aws-lambda'; import context from '@aws-lambda-powertools/testing-utils/context'; import { IdempotencyRecordStatus } from '../../src/constants.js'; +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; const mockSaveInProgress = jest .spyOn(BasePersistenceLayer.prototype, 'saveInProgress') @@ -86,7 +87,10 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler = testingKey: keyValueToBeSaved, otherKey: 'thisWillNot', }; - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); describe('When wrapping a function with no previous executions', () => { beforeEach(async () => { @@ -313,4 +317,34 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler = delete process.env.POWERTOOLS_IDEMPOTENCY_DISABLED; }); }); + + it('maintains the scope of the decorated function', async () => { + // Prepare + class TestClass implements LambdaInterface { + private readonly foo = 'foo'; + + @idempotent({ + persistenceStore: new PersistenceLayerTestClass(), + }) + public async handler( + _event: unknown, + _context: Context + ): Promise { + return this.privateMethod(); + } + + public privateMethod(): string { + return `private ${this.foo}`; + } + } + + const handlerClass = new TestClass(); + const handler = handlerClass.handler.bind(handlerClass); + + // Act + const result = await handler({}, context); + + // Assess + expect(result).toBe('private foo'); + }); });