Skip to content

Commit 22ec1f0

Browse files
authored
fix(idempotency): preserve scope of decorated class (#2693)
1 parent 266b19c commit 22ec1f0

6 files changed

+73
-10
lines changed

Diff for: packages/idempotency/src/IdempotencyHandler.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Handler } from 'aws-lambda';
12
import type {
23
JSONValue,
34
MiddyLikeRequest,
@@ -53,6 +54,12 @@ export class IdempotencyHandler<Func extends AnyFunction> {
5354
* Persistence layer used to store the idempotency records.
5455
*/
5556
readonly #persistenceStore: BasePersistenceLayer;
57+
/**
58+
* The `this` context to be used when calling the function.
59+
*
60+
* When decorating a class method, this will be the instance of the class.
61+
*/
62+
readonly #thisArg?: Handler;
5663

5764
public constructor(options: IdempotencyHandlerOptions) {
5865
const {
@@ -61,11 +68,13 @@ export class IdempotencyHandler<Func extends AnyFunction> {
6168
idempotencyConfig,
6269
functionArguments,
6370
persistenceStore,
71+
thisArg,
6472
} = options;
6573
this.#functionToMakeIdempotent = functionToMakeIdempotent;
6674
this.#functionPayloadToBeHashed = functionPayloadToBeHashed;
6775
this.#idempotencyConfig = idempotencyConfig;
6876
this.#functionArguments = functionArguments;
77+
this.#thisArg = thisArg;
6978

7079
this.#persistenceStore = persistenceStore;
7180

@@ -121,7 +130,10 @@ export class IdempotencyHandler<Func extends AnyFunction> {
121130
public async getFunctionResult(): Promise<ReturnType<Func>> {
122131
let result;
123132
try {
124-
result = await this.#functionToMakeIdempotent(...this.#functionArguments);
133+
result = await this.#functionToMakeIdempotent.apply(
134+
this.#thisArg,
135+
this.#functionArguments
136+
);
125137
} catch (error) {
126138
await this.#deleteInProgressRecord();
127139
throw error;
@@ -149,7 +161,10 @@ export class IdempotencyHandler<Func extends AnyFunction> {
149161
public async handle(): Promise<ReturnType<Func>> {
150162
// early return if we should skip idempotency completely
151163
if (this.shouldSkipIdempotency()) {
152-
return await this.#functionToMakeIdempotent(...this.#functionArguments);
164+
return await this.#functionToMakeIdempotent.apply(
165+
this.#thisArg,
166+
this.#functionArguments
167+
);
153168
}
154169

155170
let e;

Diff for: packages/idempotency/src/idempotencyDecorator.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Handler } from 'aws-lambda';
12
import {
23
AnyFunction,
34
ItempotentFunctionOptions,
@@ -65,7 +66,10 @@ const idempotent = function (
6566
descriptor: PropertyDescriptor
6667
) {
6768
const childFunction = descriptor.value;
68-
descriptor.value = makeIdempotent(childFunction, options);
69+
70+
descriptor.value = async function (this: Handler, ...args: unknown[]) {
71+
return makeIdempotent(childFunction, options).bind(this)(...args);
72+
};
6973

7074
return descriptor;
7175
};

Diff for: packages/idempotency/src/makeIdempotent.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,17 @@ const isOptionsWithDataIndexArgument = (
7272
*
7373
* ```
7474
*/
75-
const makeIdempotent = <Func extends AnyFunction>(
75+
// eslint-disable-next-line func-style
76+
function makeIdempotent<Func extends AnyFunction>(
7677
fn: Func,
7778
options: ItempotentFunctionOptions<Parameters<Func>>
78-
): ((...args: Parameters<Func>) => ReturnType<Func>) => {
79+
): (...args: Parameters<Func>) => ReturnType<Func> {
7980
const { persistenceStore, config } = options;
8081
const idempotencyConfig = config ? config : new IdempotencyConfig({});
8182

8283
if (!idempotencyConfig.isEnabled()) return fn;
8384

84-
return (...args: Parameters<Func>): ReturnType<Func> => {
85+
return function (this: Handler, ...args: Parameters<Func>): ReturnType<Func> {
8586
let functionPayloadToBeHashed;
8687

8788
if (isFnHandler(fn, args)) {
@@ -101,8 +102,9 @@ const makeIdempotent = <Func extends AnyFunction>(
101102
persistenceStore: persistenceStore,
102103
functionArguments: args,
103104
functionPayloadToBeHashed,
105+
thisArg: this,
104106
}).handle() as ReturnType<Func>;
105107
};
106-
};
108+
}
107109

108110
export { makeIdempotent };

Diff for: packages/idempotency/src/types/IdempotencyOptions.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Context } from 'aws-lambda';
1+
import type { Context, Handler } from 'aws-lambda';
22
import type { BasePersistenceLayer } from '../persistence/BasePersistenceLayer.js';
33
import type { IdempotencyConfig } from '../IdempotencyConfig.js';
44
import type { JSONValue } from '@aws-lambda-powertools/commons/types';
@@ -139,6 +139,12 @@ type IdempotencyHandlerOptions = {
139139
* Persistence layer used to store the idempotency records.
140140
*/
141141
persistenceStore: BasePersistenceLayer;
142+
/**
143+
* The `this` context to be used when calling the function.
144+
*
145+
* When decorating a class method, this will be the instance of the class.
146+
*/
147+
thisArg?: Handler;
142148
};
143149

144150
/**

Diff for: packages/idempotency/tests/e2e/idempotentDecorator.test.FunctionCode.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ const dynamoDBPersistenceLayerCustomized = new DynamoDBPersistenceLayer({
2525
const config = new IdempotencyConfig({});
2626

2727
class DefaultLambda implements LambdaInterface {
28+
private readonly message = 'Got test event:';
29+
2830
@idempotent({ persistenceStore: dynamoDBPersistenceLayer })
2931
public async handler(
3032
_event: Record<string, unknown>,
3133
_context: Context
3234
): Promise<void> {
33-
logger.info(`Got test event: ${JSON.stringify(_event)}`);
35+
logger.info(`${this.message} ${JSON.stringify(_event)}`);
3436
// sleep to enforce error with parallel execution
3537
await new Promise((resolve) => setTimeout(resolve, 1000));
3638

Diff for: packages/idempotency/tests/unit/idempotencyDecorator.test.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { IdempotencyRecordOptions } from '../../src/types/index.js';
1919
import { Context } from 'aws-lambda';
2020
import context from '@aws-lambda-powertools/testing-utils/context';
2121
import { IdempotencyRecordStatus } from '../../src/constants.js';
22+
import type { LambdaInterface } from '@aws-lambda-powertools/commons/types';
2223

2324
const mockSaveInProgress = jest
2425
.spyOn(BasePersistenceLayer.prototype, 'saveInProgress')
@@ -86,7 +87,10 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
8687
testingKey: keyValueToBeSaved,
8788
otherKey: 'thisWillNot',
8889
};
89-
beforeEach(() => jest.clearAllMocks());
90+
beforeEach(() => {
91+
jest.clearAllMocks();
92+
jest.resetAllMocks();
93+
});
9094

9195
describe('When wrapping a function with no previous executions', () => {
9296
beforeEach(async () => {
@@ -313,4 +317,34 @@ describe('Given a class with a function to decorate', (classWithLambdaHandler =
313317
delete process.env.POWERTOOLS_IDEMPOTENCY_DISABLED;
314318
});
315319
});
320+
321+
it('maintains the scope of the decorated function', async () => {
322+
// Prepare
323+
class TestClass implements LambdaInterface {
324+
private readonly foo = 'foo';
325+
326+
@idempotent({
327+
persistenceStore: new PersistenceLayerTestClass(),
328+
})
329+
public async handler(
330+
_event: unknown,
331+
_context: Context
332+
): Promise<string> {
333+
return this.privateMethod();
334+
}
335+
336+
public privateMethod(): string {
337+
return `private ${this.foo}`;
338+
}
339+
}
340+
341+
const handlerClass = new TestClass();
342+
const handler = handlerClass.handler.bind(handlerClass);
343+
344+
// Act
345+
const result = await handler({}, context);
346+
347+
// Assess
348+
expect(result).toBe('private foo');
349+
});
316350
});

0 commit comments

Comments
 (0)