Skip to content

Commit 12ac2e4

Browse files
arnabrahmansvozza
andauthored
feat(event-handler): add support for AppSync GraphQL batch resolvers (#4218)
Co-authored-by: Stefano Vozza <[email protected]>
1 parent 35bc4b4 commit 12ac2e4

File tree

11 files changed

+1397
-45
lines changed

11 files changed

+1397
-45
lines changed

packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts

Lines changed: 231 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import type { AppSyncResolverEvent, Context } from 'aws-lambda';
2+
import type {
3+
BatchResolverAggregateHandlerFn,
4+
BatchResolverHandlerFn,
5+
ResolverHandler,
6+
RouteHandlerOptions,
7+
} from '../types/appsync-graphql.js';
28
import type { ResolveOptions } from '../types/common.js';
3-
import { ResolverNotFoundException } from './errors.js';
9+
import {
10+
InvalidBatchResponseException,
11+
ResolverNotFoundException,
12+
} from './errors.js';
413
import { Router } from './Router.js';
514
import { isAppSyncGraphQLEvent } from './utils.js';
615

@@ -58,6 +67,28 @@ class AppSyncGraphQLResolver extends Router {
5867
* app.resolve(event, context);
5968
* ```
6069
*
70+
* Resolves the response based on the provided batch event and route handlers configured.
71+
*
72+
* @example
73+
* ```ts
74+
* import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
75+
*
76+
* const app = new AppSyncGraphQLResolver();
77+
*
78+
* app.batchResolver<{ id: number }>(async (events) => {
79+
* // your business logic here
80+
* const ids = events.map((event) => event.arguments.id);
81+
* return ids.map((id) => ({
82+
* id,
83+
* title: 'Post Title',
84+
* content: 'Post Content',
85+
* }));
86+
* });
87+
*
88+
* export const handler = async (event, context) =>
89+
* app.resolve(event, context);
90+
* ```
91+
*
6192
* The method works also as class method decorator, so you can use it like this:
6293
*
6394
* @example
@@ -88,6 +119,35 @@ class AppSyncGraphQLResolver extends Router {
88119
* export const handler = lambda.handler.bind(lambda);
89120
* ```
90121
*
122+
* @example
123+
* ```ts
124+
* import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
125+
* import type { AppSyncResolverEvent } from 'aws-lambda';
126+
*
127+
* const app = new AppSyncGraphQLResolver();
128+
*
129+
* class Lambda {
130+
* ⁣@app.batchResolver({ fieldName: 'getPosts', typeName: 'Query' })
131+
* async getPosts(events: AppSyncResolverEvent<{ id: number }>[]) {
132+
* // your business logic here
133+
* const ids = events.map((event) => event.arguments.id);
134+
* return ids.map((id) => ({
135+
* id,
136+
* title: 'Post Title',
137+
* content: 'Post Content',
138+
* }));
139+
* }
140+
*
141+
* async handler(event, context) {
142+
* return app.resolve(event, context, {
143+
* scope: this, // bind decorated methods to the class instance
144+
* });
145+
* }
146+
* }
147+
*
148+
* const lambda = new Lambda();
149+
* export const handler = lambda.handler.bind(lambda);
150+
* ```
91151
* @param event - The incoming event, which may be an AppSync GraphQL event or an array of events.
92152
* @param context - The AWS Lambda context object.
93153
* @param options - Optional parameters for the resolver, such as the scope of the handler.
@@ -98,27 +158,185 @@ class AppSyncGraphQLResolver extends Router {
98158
options?: ResolveOptions
99159
): Promise<unknown> {
100160
if (Array.isArray(event)) {
101-
this.logger.warn('Batch resolver is not implemented yet');
102-
return;
161+
if (event.some((e) => !isAppSyncGraphQLEvent(e))) {
162+
this.logger.warn(
163+
'Received a batch event that is not compatible with this resolver'
164+
);
165+
return;
166+
}
167+
return this.#withErrorHandling(
168+
() => this.#executeBatchResolvers(event, context, options),
169+
event[0]
170+
);
103171
}
104172
if (!isAppSyncGraphQLEvent(event)) {
105173
this.logger.warn(
106174
'Received an event that is not compatible with this resolver'
107175
);
108176
return;
109177
}
178+
179+
return this.#withErrorHandling(
180+
() => this.#executeSingleResolver(event, context, options),
181+
event
182+
);
183+
}
184+
185+
/**
186+
* Executes the provided asynchronous function with error handling.
187+
* If the function throws an error, it delegates error processing to `#handleError`
188+
* and returns the formatted error response.
189+
*
190+
* @param fn - A function returning a Promise to be executed with error handling.
191+
* @param event - The AppSync resolver event (single or first of batch).
192+
*/
193+
async #withErrorHandling(
194+
fn: () => Promise<unknown>,
195+
event: AppSyncResolverEvent<Record<string, unknown>>
196+
): Promise<unknown> {
110197
try {
111-
return await this.#executeSingleResolver(event, context, options);
198+
return await fn();
112199
} catch (error) {
113-
this.logger.error(
114-
`An error occurred in handler ${event.info.fieldName}`,
115-
error
200+
return this.#handleError(
201+
error,
202+
`An error occurred in handler ${event.info.fieldName}`
116203
);
117-
if (error instanceof ResolverNotFoundException) throw error;
118-
return this.#formatErrorResponse(error);
119204
}
120205
}
121206

207+
/**
208+
* Handles errors encountered during resolver execution.
209+
*
210+
* Logs the provided error message and error object. If the error is an instance of
211+
* `InvalidBatchResponseException` or `ResolverNotFoundException`, it is re-thrown.
212+
* Otherwise, the error is formatted into a response using `#formatErrorResponse`.
213+
*
214+
* @param error - The error object to handle.
215+
* @param errorMessage - A descriptive message to log alongside the error.
216+
* @throws InvalidBatchResponseException | ResolverNotFoundException
217+
*/
218+
#handleError(error: unknown, errorMessage: string) {
219+
this.logger.error(errorMessage, error);
220+
if (error instanceof InvalidBatchResponseException) throw error;
221+
if (error instanceof ResolverNotFoundException) throw error;
222+
return this.#formatErrorResponse(error);
223+
}
224+
225+
/**
226+
* Executes batch resolvers for multiple AppSync GraphQL events.
227+
*
228+
* This method processes an array of AppSync resolver events as a batch operation.
229+
* It looks up the appropriate batch resolver from the registry using the field name
230+
* and parent type name from the first event, then delegates to the batch resolver
231+
* if found.
232+
*
233+
* @param events - Array of AppSync resolver events to process as a batch
234+
* @param context - AWS Lambda context object
235+
* @param options - Optional resolve options for customizing resolver behavior
236+
* @throws {ResolverNotFoundException} When no batch resolver is registered for the given type and field combination
237+
*/
238+
async #executeBatchResolvers(
239+
events: AppSyncResolverEvent<Record<string, unknown>>[],
240+
context: Context,
241+
options?: ResolveOptions
242+
): Promise<unknown[]> {
243+
const { fieldName, parentTypeName: typeName } = events[0].info;
244+
const batchHandlerOptions = this.batchResolverRegistry.resolve(
245+
typeName,
246+
fieldName
247+
);
248+
249+
if (batchHandlerOptions) {
250+
return await this.#callBatchResolver(
251+
events,
252+
context,
253+
batchHandlerOptions,
254+
options
255+
);
256+
}
257+
258+
throw new ResolverNotFoundException(
259+
`No batch resolver found for ${typeName}-${fieldName}`
260+
);
261+
}
262+
263+
/**
264+
* Handles batch invocation of AppSync GraphQL resolvers with support for aggregation and error handling.
265+
*
266+
* @param events - An array of AppSyncResolverEvent objects representing the batch of incoming events.
267+
* @param context - The Lambda context object.
268+
* @param options - Route handler options, including the handler function, aggregation, and error handling flags.
269+
* @param resolveOptions - Optional resolve options, such as custom scope for handler invocation.
270+
*
271+
* @throws {InvalidBatchResponseException} If the aggregate handler does not return an array.
272+
*
273+
* @remarks
274+
* - If `aggregate` is true, invokes the handler once with the entire batch and expects an array response.
275+
* - If `throwOnError` is true, errors are propagated and will cause the function to throw.
276+
* - If `throwOnError` is false, errors are logged and `null` is appended for failed events, allowing graceful degradation.
277+
*/
278+
async #callBatchResolver(
279+
events: AppSyncResolverEvent<Record<string, unknown>>[],
280+
context: Context,
281+
options: RouteHandlerOptions<Record<string, unknown>, boolean, boolean>,
282+
resolveOptions?: ResolveOptions
283+
): Promise<unknown[]> {
284+
const { aggregate, throwOnError } = options;
285+
this.logger.debug(
286+
`Aggregate flag aggregate=${aggregate} & graceful error handling flag throwOnError=${throwOnError}`
287+
);
288+
289+
if (aggregate) {
290+
const response = await (
291+
options.handler as BatchResolverAggregateHandlerFn
292+
).apply(resolveOptions?.scope ?? this, [
293+
events,
294+
{ event: events, context },
295+
]);
296+
297+
if (!Array.isArray(response)) {
298+
throw new InvalidBatchResponseException(
299+
'The response must be an array when using batch resolvers'
300+
);
301+
}
302+
303+
return response;
304+
}
305+
306+
const handler = options.handler as BatchResolverHandlerFn;
307+
const results: unknown[] = [];
308+
309+
if (throwOnError) {
310+
for (const event of events) {
311+
const result = await handler.apply(resolveOptions?.scope ?? this, [
312+
event.arguments,
313+
{ event, context },
314+
]);
315+
results.push(result);
316+
}
317+
return results;
318+
}
319+
320+
for (let i = 0; i < events.length; i++) {
321+
try {
322+
const result = await handler.apply(resolveOptions?.scope ?? this, [
323+
events[i].arguments,
324+
{ event: events[i], context },
325+
]);
326+
results.push(result);
327+
} catch (error) {
328+
this.logger.error(error);
329+
this.logger.debug(
330+
`Failed to process event #${i + 1} from field '${events[i].info.fieldName}'`
331+
);
332+
// By default, we gracefully append `null` for any records that failed processing
333+
results.push(null);
334+
}
335+
}
336+
337+
return results;
338+
}
339+
122340
/**
123341
* Executes the appropriate resolver for a given AppSync GraphQL event.
124342
*
@@ -143,10 +361,10 @@ class AppSyncGraphQLResolver extends Router {
143361
fieldName
144362
);
145363
if (resolverHandlerOptions) {
146-
return resolverHandlerOptions.handler.apply(options?.scope ?? this, [
147-
event.arguments,
148-
{ event, context },
149-
]);
364+
return (resolverHandlerOptions.handler as ResolverHandler).apply(
365+
options?.scope ?? this,
366+
[event.arguments, { event, context }]
367+
);
150368
}
151369

152370
throw new ResolverNotFoundException(

packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ class RouteHandlerRegistry {
1515
/**
1616
* A map of registered route handlers, keyed by their type & field name.
1717
*/
18-
protected readonly resolvers: Map<string, RouteHandlerOptions> = new Map();
18+
protected readonly resolvers: Map<
19+
string,
20+
RouteHandlerOptions<Record<string, unknown>, boolean, boolean>
21+
> = new Map();
1922
/**
2023
* A logger instance to be used for logging debug and warning messages.
2124
*/
@@ -34,8 +37,10 @@ class RouteHandlerRegistry {
3437
* @param options.typeName - The name of the GraphQL type to be registered
3538
*
3639
*/
37-
public register(options: RouteHandlerOptions): void {
38-
const { fieldName, handler, typeName } = options;
40+
public register(
41+
options: RouteHandlerOptions<Record<string, unknown>, boolean, boolean>
42+
): void {
43+
const { fieldName, handler, typeName, throwOnError, aggregate } = options;
3944
this.#logger.debug(`Adding resolver for field ${typeName}.${fieldName}`);
4045
const cacheKey = this.#makeKey(typeName, fieldName);
4146
if (this.resolvers.has(cacheKey)) {
@@ -47,6 +52,8 @@ class RouteHandlerRegistry {
4752
fieldName,
4853
handler,
4954
typeName,
55+
throwOnError,
56+
aggregate,
5057
});
5158
}
5259

@@ -59,7 +66,9 @@ class RouteHandlerRegistry {
5966
public resolve(
6067
typeName: string,
6168
fieldName: string
62-
): RouteHandlerOptions | undefined {
69+
):
70+
| RouteHandlerOptions<Record<string, unknown>, boolean, boolean>
71+
| undefined {
6372
this.#logger.debug(
6473
`Looking for resolver for type=${typeName}, field=${fieldName}`
6574
);

0 commit comments

Comments
 (0)