Skip to content

Commit e539719

Browse files
authored
fix(event-handler): fix decorated scope in appsync events (#3974)
1 parent 84b039a commit e539719

File tree

5 files changed

+196
-24
lines changed

5 files changed

+196
-24
lines changed

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

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
OnPublishHandlerAggregateFn,
66
OnPublishHandlerFn,
77
OnSubscribeHandler,
8+
ResolveOptions,
89
} from '../types/appsync-events.js';
910
import { Router } from './Router.js';
1011
import { UnauthorizedException } from './errors.js';
@@ -67,7 +68,9 @@ class AppSyncEventsResolver extends Router {
6768
* }
6869
*
6970
* async handler(event, context) {
70-
* return app.resolve(event, context);
71+
* return app.resolve(event, context, {
72+
* scope: this, // bind decorated methods to the class instance
73+
* });
7174
* }
7275
* }
7376
*
@@ -78,7 +81,11 @@ class AppSyncEventsResolver extends Router {
7881
* @param event - The incoming event from AppSync Events
7982
* @param context - The context object provided by AWS Lambda
8083
*/
81-
public async resolve(event: unknown, context: Context) {
84+
public async resolve(
85+
event: unknown,
86+
context: Context,
87+
options?: ResolveOptions
88+
) {
8289
if (!isAppSyncEventsEvent(event)) {
8390
this.logger.warn(
8491
'Received an event that is not compatible with this resolver'
@@ -87,11 +94,12 @@ class AppSyncEventsResolver extends Router {
8794
}
8895

8996
if (isAppSyncEventsPublishEvent(event)) {
90-
return await this.handleOnPublish(event, context);
97+
return await this.handleOnPublish(event, context, options);
9198
}
9299
return await this.handleOnSubscribe(
93100
event as AppSyncEventsSubscribeEvent,
94-
context
101+
context,
102+
options
95103
);
96104
}
97105

@@ -100,10 +108,12 @@ class AppSyncEventsResolver extends Router {
100108
*
101109
* @param event - The incoming event from AppSync Events
102110
* @param context - The context object provided by AWS Lambda
111+
* @param options - Optional resolve options
103112
*/
104113
protected async handleOnPublish(
105114
event: AppSyncEventsPublishEvent,
106-
context: Context
115+
context: Context,
116+
options?: ResolveOptions
107117
) {
108118
const { path } = event.info.channel;
109119
const routeHandlerOptions = this.onPublishRegistry.resolve(path);
@@ -114,11 +124,10 @@ class AppSyncEventsResolver extends Router {
114124
if (aggregate) {
115125
try {
116126
return {
117-
events: await (handler as OnPublishHandlerAggregateFn).apply(this, [
118-
event.events,
119-
event,
120-
context,
121-
]),
127+
events: await (handler as OnPublishHandlerAggregateFn).apply(
128+
options?.scope ?? this,
129+
[event.events, event, context]
130+
),
122131
};
123132
} catch (error) {
124133
this.logger.error(`An error occurred in handler ${path}`, error);
@@ -131,11 +140,10 @@ class AppSyncEventsResolver extends Router {
131140
event.events.map(async (message) => {
132141
const { id, payload } = message;
133142
try {
134-
const result = await (handler as OnPublishHandlerFn).apply(this, [
135-
payload,
136-
event,
137-
context,
138-
]);
143+
const result = await (handler as OnPublishHandlerFn).apply(
144+
options?.scope ?? this,
145+
[payload, event, context]
146+
);
139147
return {
140148
id,
141149
payload: result,
@@ -161,10 +169,12 @@ class AppSyncEventsResolver extends Router {
161169
*
162170
* @param event - The incoming event from AppSync Events
163171
* @param context - The context object provided by AWS Lambda
172+
* @param options - Optional resolve options
164173
*/
165174
protected async handleOnSubscribe(
166175
event: AppSyncEventsSubscribeEvent,
167-
context: Context
176+
context: Context,
177+
options?: ResolveOptions
168178
) {
169179
const { path } = event.info.channel;
170180
const routeHandlerOptions = this.onSubscribeRegistry.resolve(path);
@@ -173,7 +183,10 @@ class AppSyncEventsResolver extends Router {
173183
}
174184
const { handler } = routeHandlerOptions;
175185
try {
176-
await (handler as OnSubscribeHandler).apply(this, [event, context]);
186+
await (handler as OnSubscribeHandler).apply(options?.scope ?? this, [
187+
event,
188+
context,
189+
]);
177190
} catch (error) {
178191
this.logger.error(`An error occurred in handler ${path}`, error);
179192
if (error instanceof UnauthorizedException) throw error;

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

Lines changed: 6 additions & 3 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,11 @@ 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;
199202
this.onPublishRegistry.register({
200203
path,
201-
handler: descriptor.value,
204+
handler: descriptor?.value,
202205
aggregate: (routeOptions?.aggregate ?? false) as T,
203206
});
204207
return descriptor;
@@ -276,7 +279,7 @@ class Router {
276279
return (_target, _propertyKey, descriptor: PropertyDescriptor) => {
277280
this.onSubscribeRegistry.register({
278281
path,
279-
handler: descriptor.value,
282+
handler: descriptor?.value,
280283
});
281284
return descriptor;
282285
};

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

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,47 @@
11
import type { Context } from 'aws-lambda';
2+
import type { AppSyncEventsResolver } from '../appsync-events/AppSyncEventsResolver.js';
23
import type { RouteHandlerRegistry } from '../appsync-events/RouteHandlerRegistry.js';
34
import type { Router } from '../appsync-events/Router.js';
45
import type { Anything, GenericLogger } from './common.js';
56

7+
// #region resolve options
8+
9+
/**
10+
* Optional object to pass to the {@link AppSyncEventsResolver.resolve | `AppSyncEventsResolver.resolve()`} method.
11+
*/
12+
type ResolveOptions = {
13+
/**
14+
* Reference to `this` instance of the class that is calling the `resolve` method.
15+
*
16+
* This parameter should be used only when using {@link AppSyncEventsResolver.onPublish | `AppSyncEventsResolver.onPublish()`}
17+
* and {@link AppSyncEventsResolver.onSubscribe | `AppSyncEventsResolver.onSubscribe()`} as class method decorators, and
18+
* it's used to bind the decorated methods to your class instance.
19+
*
20+
* @example
21+
* ```ts
22+
* import { AppSyncEventsResolver } from '@aws-lambda-powertools/event-handler/appsync-events';
23+
*
24+
* const app = new AppSyncEventsResolver();
25+
*
26+
* class Lambda {
27+
* public scope = 'scoped';
28+
*
29+
* ⁣@app.onPublish('/foo')
30+
* public async handleFoo(payload: string) {
31+
* return `${this.scope} ${payload}`;
32+
* }
33+
*
34+
* public async handler(event: unknown, context: Context) {
35+
* return app.resolve(event, context, { scope: this });
36+
* }
37+
* }
38+
* const lambda = new Lambda();
39+
* const handler = lambda.handler.bind(lambda);
40+
* ```
41+
*/
42+
scope?: unknown;
43+
};
44+
645
// #region OnPublish fn
746

847
type OnPublishHandlerFn = (
@@ -17,11 +56,13 @@ type OnPublishHandlerSyncFn = (
1756
context: Context
1857
) => unknown;
1958

59+
type OnPublishAggregatePayload = Array<{
60+
payload: Anything;
61+
id: string;
62+
}>;
63+
2064
type OnPublishHandlerAggregateFn = (
21-
events: Array<{
22-
payload: Anything;
23-
id: string;
24-
}>,
65+
events: OnPublishAggregatePayload,
2566
event: AppSyncEventsPublishEvent,
2667
context: Context
2768
) => Promise<unknown[]>;
@@ -294,8 +335,10 @@ export type {
294335
OnPublishHandlerSyncFn,
295336
OnPublishHandlerSyncAggregateFn,
296337
OnPublishHandlerAggregateFn,
338+
OnPublishAggregatePayload,
297339
OnSubscribeHandler,
298340
OnPublishAggregateOutput,
299341
OnPublishEventPayload,
300342
OnPublishOutput,
343+
ResolveOptions,
301344
};

packages/event-handler/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ export type {
33
AppSyncEventsPublishEvent,
44
AppSyncEventsSubscribeEvent,
55
OnPublishAggregateOutput,
6+
OnPublishAggregatePayload,
67
OnPublishEventPayload,
78
OnPublishOutput,
89
RouteOptions,
910
RouterOptions,
11+
ResolveOptions,
1012
} from './appsync-events.js';
1113

1214
export type {

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
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,
67
} from '../../../src/appsync-events/index.js';
8+
import type {
9+
AppSyncEventsSubscribeEvent,
10+
OnPublishAggregatePayload,
11+
} from '../../../src/types/appsync-events.js';
712
import {
813
onPublishEventFactory,
914
onSubscribeEventFactory,
@@ -63,6 +68,112 @@ describe('Class: AppSyncEventsResolver', () => {
6368
});
6469
});
6570

71+
it.each([
72+
{ aggregate: true, channel: { path: '/foo', segments: ['foo'] } },
73+
{
74+
aggregate: false,
75+
channel: {
76+
path: '/bar',
77+
segments: ['bar'],
78+
},
79+
},
80+
])(
81+
'preserves the scope when decorating with onPublish aggregate=$aggregate',
82+
async ({ aggregate, channel }) => {
83+
// Prepare
84+
const app = new AppSyncEventsResolver({ logger: console });
85+
86+
class Lambda {
87+
public scope = 'scoped';
88+
89+
@app.onPublish('/foo', { aggregate })
90+
public async handleFoo(payloads: OnPublishAggregatePayload) {
91+
return payloads.map((payload) => {
92+
return {
93+
id: payload.id,
94+
payload: `${this.scope} ${payload.payload}`,
95+
};
96+
});
97+
}
98+
99+
@app.onPublish('/bar')
100+
public async handleBar(payload: string) {
101+
return `${this.scope} ${payload}`;
102+
}
103+
104+
public async handler(event: unknown, context: Context) {
105+
return this.stuff(event, context);
106+
}
107+
108+
async stuff(event: unknown, context: Context) {
109+
return app.resolve(event, context, { scope: this });
110+
}
111+
}
112+
const lambda = new Lambda();
113+
const handler = lambda.handler.bind(lambda);
114+
115+
// Act
116+
const result = await handler(
117+
onPublishEventFactory(
118+
[
119+
{
120+
id: '1',
121+
payload: 'foo',
122+
},
123+
],
124+
channel
125+
),
126+
context
127+
);
128+
129+
// Assess
130+
expect(result).toEqual({
131+
events: [
132+
{
133+
id: '1',
134+
payload: 'scoped foo',
135+
},
136+
],
137+
});
138+
}
139+
);
140+
141+
it('preserves the scope when decorating with onSubscribe', async () => {
142+
// Prepare
143+
const app = new AppSyncEventsResolver({ logger: console });
144+
145+
class Lambda {
146+
public scope = 'scoped';
147+
148+
@app.onSubscribe('/foo')
149+
public async handleFoo(payload: AppSyncEventsSubscribeEvent) {
150+
console.debug(`${this.scope} ${payload.info.channel.path}`);
151+
}
152+
153+
public async handler(event: unknown, context: Context) {
154+
return this.stuff(event, context);
155+
}
156+
157+
async stuff(event: unknown, context: Context) {
158+
return app.resolve(event, context, { scope: this });
159+
}
160+
}
161+
const lambda = new Lambda();
162+
const handler = lambda.handler.bind(lambda);
163+
164+
// Act
165+
await handler(
166+
onSubscribeEventFactory({
167+
path: '/foo',
168+
segments: ['foo'],
169+
}),
170+
context
171+
);
172+
173+
// Assess
174+
expect(console.debug).toHaveBeenCalledWith('scoped /foo');
175+
});
176+
66177
it('returns null if there are no onSubscribe handlers', async () => {
67178
// Prepare
68179
const app = new AppSyncEventsResolver({ logger: console });

0 commit comments

Comments
 (0)