Skip to content

Commit 9b21ea3

Browse files
committed
fix(event-handler): fix decorated scope in appsync events
1 parent ddfb66c commit 9b21ea3

File tree

3 files changed

+191
-13
lines changed

3 files changed

+191
-13
lines changed

packages/event-handler/src/appsync-events/AppSyncEventsResolver.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ class AppSyncEventsResolver extends Router {
114114
if (aggregate) {
115115
try {
116116
return {
117-
events: await (handler as OnPublishHandlerAggregateFn).apply(this, [
117+
events: await (handler as OnPublishHandlerAggregateFn)(
118118
event.events,
119119
event,
120-
context,
121-
]),
120+
context
121+
),
122122
};
123123
} catch (error) {
124124
this.logger.error(`An error occurred in handler ${path}`, error);
@@ -131,11 +131,11 @@ class AppSyncEventsResolver extends Router {
131131
event.events.map(async (message) => {
132132
const { id, payload } = message;
133133
try {
134-
const result = await (handler as OnPublishHandlerFn).apply(this, [
134+
const result = await (handler as OnPublishHandlerFn)(
135135
payload,
136136
event,
137-
context,
138-
]);
137+
context
138+
);
139139
return {
140140
id,
141141
payload: result,
@@ -173,7 +173,7 @@ class AppSyncEventsResolver extends Router {
173173
}
174174
const { handler } = routeHandlerOptions;
175175
try {
176-
await (handler as OnSubscribeHandler).apply(this, [event, context]);
176+
await (handler as OnSubscribeHandler)(event, context);
177177
} catch (error) {
178178
this.logger.error(`An error occurred in handler ${path}`, error);
179179
if (error instanceof UnauthorizedException) throw error;

packages/event-handler/src/appsync-events/Router.ts

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import type {
99
} from '../types/appsync-events.js';
1010
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
1111

12+
// Simple global approach - store the last instance per router
13+
const routerInstanceMap = new WeakMap<Router, unknown>();
14+
1215
/**
1316
* Class for registering routes for the `onPublish` and `onSubscribe` events in AWS AppSync Events APIs.
1417
*/
@@ -194,11 +197,22 @@ class Router {
194197
return;
195198
}
196199

197-
return (_target, _propertyKey, descriptor: PropertyDescriptor) => {
200+
return (target, _propertyKey, descriptor: PropertyDescriptor) => {
198201
const routeOptions = isRecord(handler) ? handler : options;
202+
const originalMethod = descriptor?.value;
203+
const routerInstance = this;
204+
205+
this.#bindResolveMethodScope(target);
206+
207+
// Create a handler that uses the captured instance
208+
const boundHandler = (...args: unknown[]) => {
209+
const instance = routerInstanceMap.get(routerInstance);
210+
return originalMethod?.apply(instance, args);
211+
};
212+
199213
this.onPublishRegistry.register({
200214
path,
201-
handler: descriptor.value,
215+
handler: boundHandler,
202216
aggregate: (routeOptions?.aggregate ?? false) as T,
203217
});
204218
return descriptor;
@@ -273,14 +287,89 @@ class Router {
273287
return;
274288
}
275289

276-
return (_target, _propertyKey, descriptor: PropertyDescriptor) => {
290+
return (target, propertyKey, descriptor: PropertyDescriptor) => {
291+
const originalMethod = descriptor?.value;
292+
const routerInstance = this;
293+
294+
// Patch any method that might call resolve() to capture instance
295+
this.#bindResolveMethodScope(target);
296+
297+
// Create a handler that uses the captured instance
298+
const boundHandler = (...args: unknown[]) => {
299+
const instance = routerInstanceMap.get(routerInstance);
300+
return originalMethod?.apply(instance, args);
301+
};
302+
277303
this.onSubscribeRegistry.register({
278304
path,
279-
handler: descriptor.value,
305+
handler: boundHandler,
280306
});
281307
return descriptor;
282308
};
283309
}
310+
311+
/**
312+
* Binds the resolve method scope to the target object.
313+
*
314+
* We patch any method that might call `resolve()` to ensure that
315+
* the class instance is captured correctly when the method is resolved. We need
316+
* to do this because when a method is decorated, it loses its context and
317+
* the `this` keyword inside the method no longer refers to the class instance of the decorated method.
318+
*
319+
* We need to apply this two-step process because the decorator is applied to the method
320+
* before the class instance is created, so we cannot capture the instance directly.
321+
*
322+
* @param target - The target object whose methods will be patched to capture the instance scope
323+
*/
324+
#bindResolveMethodScope(target: object) {
325+
const routerInstance = this;
326+
327+
// Patch any method that might call resolve() to capture instance
328+
if (!target.constructor.prototype._powertoolsPatched) {
329+
target.constructor.prototype._powertoolsPatched = true;
330+
331+
// Get all method names from the prototype
332+
const proto = target.constructor.prototype;
333+
const methodNames = Object.getOwnPropertyNames(proto);
334+
335+
for (const methodName of methodNames) {
336+
if (methodName === 'constructor') continue;
337+
338+
const methodDescriptor = Object.getOwnPropertyDescriptor(
339+
proto,
340+
methodName
341+
);
342+
if (
343+
methodDescriptor?.value &&
344+
typeof methodDescriptor.value === 'function'
345+
) {
346+
const originalMethodRef = methodDescriptor.value;
347+
const methodSource = originalMethodRef.toString();
348+
349+
// Check if this method calls .resolve() on our router instance
350+
if (
351+
methodSource.includes('.resolve(') ||
352+
methodSource.includes('.resolve ')
353+
) {
354+
const patchedMethod = function (this: unknown, ...args: unknown[]) {
355+
// Capture instance when any method that calls resolve is called
356+
if (this && typeof this === 'object') {
357+
routerInstanceMap.set(routerInstance, this);
358+
}
359+
return originalMethodRef.apply(this, args);
360+
};
361+
362+
Object.defineProperty(proto, methodName, {
363+
value: patchedMethod,
364+
writable: true,
365+
configurable: true,
366+
enumerable: true,
367+
});
368+
}
369+
}
370+
}
371+
}
372+
}
284373
}
285374

286375
export { Router };

packages/event-handler/tests/unit/AppSyncEventsResolver.test.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import context from '@aws-lambda-powertools/testing-utils/context';
2+
import type { Context } from 'aws-lambda';
23
import { beforeEach, describe, expect, it, vi } from 'vitest';
34
import {
45
AppSyncEventsResolver,
56
UnauthorizedException,
6-
} from '../../src/appsync-events/index.js';
7+
} from '../../../src/appsync-events/index.js';
8+
import type { AppSyncEventsSubscribeEvent } from '../../../src/types/appsync-events.js';
79
import {
810
onPublishEventFactory,
911
onSubscribeEventFactory,
10-
} from '../helpers/factories.js';
12+
} from '../../helpers/factories.js';
1113

1214
describe('Class: AppSyncEventsResolver', () => {
1315
beforeEach(() => {
@@ -63,6 +65,93 @@ describe('Class: AppSyncEventsResolver', () => {
6365
});
6466
});
6567

68+
it('preserves the scope when decorating methods', async () => {
69+
// Prepare
70+
const app = new AppSyncEventsResolver({ logger: console });
71+
72+
class Lambda {
73+
public scope = 'scoped';
74+
75+
@app.onPublish('/foo')
76+
public async handleFoo(payload: string) {
77+
return `${this.scope} ${payload}`;
78+
}
79+
80+
public async handler(event: unknown, context: Context) {
81+
return this.stuff(event, context);
82+
}
83+
84+
async stuff(event: unknown, context: Context) {
85+
return app.resolve(event, context);
86+
}
87+
}
88+
const lambda = new Lambda();
89+
const handler = lambda.handler.bind(lambda);
90+
91+
// Act
92+
const result = await handler(
93+
onPublishEventFactory(
94+
[
95+
{
96+
id: '1',
97+
payload: 'foo',
98+
},
99+
],
100+
{
101+
path: '/foo',
102+
segments: ['foo'],
103+
}
104+
),
105+
context
106+
);
107+
108+
// Assess
109+
expect(result).toEqual({
110+
events: [
111+
{
112+
id: '1',
113+
payload: 'scoped foo',
114+
},
115+
],
116+
});
117+
});
118+
119+
it('preserves the scope when decorating methods', async () => {
120+
// Prepare
121+
const app = new AppSyncEventsResolver({ logger: console });
122+
123+
class Lambda {
124+
public scope = 'scoped';
125+
126+
@app.onSubscribe('/foo')
127+
public async handleFoo(payload: AppSyncEventsSubscribeEvent) {
128+
console.debug(`${this.scope} ${payload.info.channel.path}`);
129+
}
130+
131+
public async handler(event: unknown, context: Context) {
132+
return this.stuff(event, context);
133+
}
134+
135+
async stuff(event: unknown, context: Context) {
136+
return app.resolve(event, context);
137+
}
138+
}
139+
const lambda = new Lambda();
140+
const handler = lambda.handler.bind(lambda);
141+
142+
// Act
143+
await handler(
144+
onSubscribeEventFactory({
145+
path: '/foo',
146+
segments: ['foo'],
147+
}),
148+
context
149+
);
150+
151+
// Assess
152+
expect(console.debug).toHaveBeenCalledWith('scoped /foo');
153+
});
154+
66155
it('returns null if there are no onSubscribe handlers', async () => {
67156
// Prepare
68157
const app = new AppSyncEventsResolver({ logger: console });

0 commit comments

Comments
 (0)