Skip to content

Commit 016cf31

Browse files
committed
fix(idempotency): preserve scope of decorated class
1 parent 266b19c commit 016cf31

File tree

5 files changed

+104
-8
lines changed

5 files changed

+104
-8
lines changed

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;

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
};

packages/idempotency/src/makeIdempotent.ts

+40-3
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ const isOptionsWithDataIndexArgument = (
7272
*
7373
* ```
7474
*/
75-
const makeIdempotent = <Func extends AnyFunction>(
75+
/* const makeIdempotent = <Func extends AnyFunction>(
7676
fn: Func,
77-
options: ItempotentFunctionOptions<Parameters<Func>>
77+
options: ItempotentFunctionOptions<Parameters<Func>>,
78+
thisArg: Handler
7879
): ((...args: Parameters<Func>) => ReturnType<Func>) => {
7980
const { persistenceStore, config } = options;
8081
const idempotencyConfig = config ? config : new IdempotencyConfig({});
@@ -101,8 +102,44 @@ const makeIdempotent = <Func extends AnyFunction>(
101102
persistenceStore: persistenceStore,
102103
functionArguments: args,
103104
functionPayloadToBeHashed,
105+
thisArg,
104106
}).handle() as ReturnType<Func>;
105107
};
106-
};
108+
}; */
109+
// eslint-disable-next-line func-style
110+
function makeIdempotent<Func extends AnyFunction>(
111+
fn: Func,
112+
options: ItempotentFunctionOptions<Parameters<Func>>
113+
): (...args: Parameters<Func>) => ReturnType<Func> {
114+
const { persistenceStore, config } = options;
115+
const idempotencyConfig = config ? config : new IdempotencyConfig({});
116+
117+
if (!idempotencyConfig.isEnabled()) return fn;
118+
119+
return function (...args: Parameters<Func>): ReturnType<Func> {
120+
let functionPayloadToBeHashed;
121+
122+
if (isFnHandler(fn, args)) {
123+
idempotencyConfig.registerLambdaContext(args[1]);
124+
functionPayloadToBeHashed = args[0];
125+
} else {
126+
if (isOptionsWithDataIndexArgument(options)) {
127+
functionPayloadToBeHashed = args[options.dataIndexArgument];
128+
} else {
129+
functionPayloadToBeHashed = args[0];
130+
}
131+
}
132+
133+
return new IdempotencyHandler({
134+
functionToMakeIdempotent: fn,
135+
idempotencyConfig: idempotencyConfig,
136+
persistenceStore: persistenceStore,
137+
functionArguments: args,
138+
functionPayloadToBeHashed,
139+
// @ts-expect-error abc
140+
thisArg: this,
141+
}).handle() as ReturnType<Func>;
142+
};
143+
}
107144

108145
export { makeIdempotent };

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
/**

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)