Skip to content

feat(event-handler): add support for AppSync GraphQL batch resolvers #4218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 59 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
278901e
feat: batch resolver type for appsync-graphql
arnabrahman Jul 2, 2025
7b279a7
feat: update default values for GraphQlBatchRouteOptions
arnabrahman Jul 2, 2025
aa5c7ab
fix: correct parameter type for BatchResolverAggregateHandlerFn
arnabrahman Jul 5, 2025
054d693
feat: implement batch resolver functionality in Router and AppSyncGra…
arnabrahman Jul 6, 2025
47717af
refactor: enhance type definitions for RouteHandlerOptions and GraphQ…
arnabrahman Jul 6, 2025
079354b
fix: simplify aggregate and raiseOnError default value handling in ba…
arnabrahman Jul 6, 2025
00c62f5
feat: add InvalidBatchResponseException and enhance error handling in…
arnabrahman Jul 17, 2025
8160cc1
fix: clarify error message for missing batch resolvers in AppSyncGrap…
arnabrahman Jul 9, 2025
0a1026c
test: batch resolver unit tests for graphql
arnabrahman Jul 9, 2025
c6259ef
refactor: enhance GraphQlBatchRouteOptions to clarify aggregation and…
arnabrahman Jul 9, 2025
0f404f9
fix: improve error handling and logging for batch events in AppSyncGr…
arnabrahman Jul 9, 2025
192015e
fix: improve batch event validation in AppSyncGraphQLResolver
arnabrahman Jul 9, 2025
e976acb
refactor: streamline GraphQlBatchRouteOptions type definition for cla…
arnabrahman Jul 9, 2025
a00f66c
feat: add batch resolver support and detailed documentation for AppSy…
arnabrahman Jul 17, 2025
9ba7e66
feat: add batch resolver execution method with detailed documentation…
arnabrahman Jul 17, 2025
b5e24b2
refactor: remove typing of aggregate handler
arnabrahman Jul 20, 2025
af35ca6
refactor: specify type parameters for resolvers in MockRouteHandlerRe…
arnabrahman Jul 20, 2025
edffb08
feat: add detailed documentation for batch resolver registration in R…
arnabrahman Jul 20, 2025
094fcb1
feat: add batch resolver registration methods for query and mutation …
arnabrahman Jul 20, 2025
3610c9d
fix: correct documentation for InvalidBatchResponseException in error…
arnabrahman Jul 20, 2025
f01d34e
refactor: simplify GraphQlBatchRouteOptions type definition
arnabrahman Jul 20, 2025
29de937
fix: update error message for batch resolver response validation
arnabrahman Jul 20, 2025
22a0a4c
fix: update loop index variable for event processing in AppSyncGraphQ…
arnabrahman Jul 20, 2025
587818f
fix: clarify template parameter descriptions in GraphQlBatchRouteOptions
arnabrahman Jul 21, 2025
92d0768
docs: add example for batch resolver usage in AppSyncGraphQLResolver
arnabrahman Jul 21, 2025
0dcc54c
fix: update batch resolver to use event.arguments.id for ID extraction
arnabrahman Jul 23, 2025
8df5f8c
fix: remove unused import and optimize event processing loop in AppSy…
arnabrahman Jul 23, 2025
1220f19
fix: parameterize BatchResolverAggregateHandlerFn and BatchResolverSy…
arnabrahman Jul 23, 2025
fa88d05
fix: clarify GraphQlBatchRouteOptions template parameter behavior reg…
arnabrahman Jul 25, 2025
1292348
fix: update batchResolver, onBatchQuery, and onBatchMutation signatur…
arnabrahman Jul 25, 2025
1311ced
fix: enhance type safety in batch resolver examples by using AppSyncR…
arnabrahman Jul 25, 2025
27a2d3a
fix: enhance documentation for batchResolver with detailed examples a…
arnabrahman Jul 25, 2025
c5e5bbb
fix: enhance documentation for batchResolver and onBatchQuery/onBatch…
arnabrahman Jul 25, 2025
0012c59
fix: improve type safety in handleBatchGet by specifying event argume…
arnabrahman Jul 25, 2025
2de1c82
fix: update batch event handling to improve error logging and simplif…
arnabrahman Jul 25, 2025
0ba0496
fix: enhance type safety in batch resolver and related handlers by in…
arnabrahman Jul 25, 2025
2457ee9
fix: rename parameter for clarity in BatchResolverAggregateHandlerFn …
arnabrahman Jul 25, 2025
321b0e1
fix: enhance type safety in BatchResolverAggregateHandlerFn and Batch…
arnabrahman Jul 25, 2025
1fc0ce7
fix: update error handling in #withErrorHandling to return formatted …
arnabrahman Jul 27, 2025
1f95054
fix: enhance type safety in BatchResolverHandler and RouteHandlerOpti…
arnabrahman Jul 27, 2025
2d03dd5
fix: enhance type safety in batch resolver methods by allowing TSourc…
arnabrahman Jul 27, 2025
c198ed6
fix: enhance type safety in batch resolver examples by specifying gen…
arnabrahman Jul 27, 2025
b02c5b6
fix: enhance type safety in batch resolver types by adding TSource pa…
arnabrahman Jul 27, 2025
a6fb353
fix: enhance type safety in batchResolver by specifying generic param…
arnabrahman Jul 27, 2025
4c01357
fix: simplify type guard for AppSync GraphQL event by removing redund…
arnabrahman Jul 27, 2025
22e79bc
fix: improve error handling by using event information in error messages
arnabrahman Jul 27, 2025
be731f0
fix: improve error handling by formatting error messages for better r…
arnabrahman Jul 27, 2025
2ab33b9
fix: update type handling in AppSyncGraphQLResolver for improved clar…
arnabrahman Jul 27, 2025
d88a3b5
fix: clarify documentation for raiseOnError option in batch processing
arnabrahman Jul 27, 2025
1f62336
fix: update documentation for raiseOnError option to clarify usage wi…
arnabrahman Jul 27, 2025
9650d80
test: enhance AppSyncGraphQLResolver tests by verifying handler call …
arnabrahman Jul 27, 2025
1f908cd
fix: add BatchResolverHandler type export to index
arnabrahman Jul 27, 2025
5d9d1c4
fix: remove unused BatchResolverHandler type import in AppSyncGraphQL…
arnabrahman Jul 27, 2025
3a75160
fix: update batch resolver scope to include raiseOnError and aggregat…
arnabrahman Jul 27, 2025
66719eb
fix: update location names in Router tests for clarity
arnabrahman Jul 27, 2025
6275b51
fix: rename raiseOnError option to throwOnError
arnabrahman Jul 28, 2025
75684b7
fix: update log message for graceful error handling flag
arnabrahman Jul 28, 2025
82701be
fix: refactor batchResolver tests to use dynamic throwOnError flag
arnabrahman Jul 28, 2025
13ebb91
fix: correct batch event wording and refactor batch resolver test cas…
arnabrahman Jul 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 231 additions & 13 deletions packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { AppSyncResolverEvent, Context } from 'aws-lambda';
import type {
BatchResolverAggregateHandlerFn,
BatchResolverHandlerFn,
ResolverHandler,
RouteHandlerOptions,
} from '../types/appsync-graphql.js';
import type { ResolveOptions } from '../types/common.js';
import { ResolverNotFoundException } from './errors.js';
import {
InvalidBatchResponseException,
ResolverNotFoundException,
} from './errors.js';
import { Router } from './Router.js';
import { isAppSyncGraphQLEvent } from './utils.js';

Expand Down Expand Up @@ -58,6 +67,28 @@ class AppSyncGraphQLResolver extends Router {
* app.resolve(event, context);
* ```
*
* Resolves the response based on the provided batch event and route handlers configured.
*
* @example
* ```ts
* import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
*
* const app = new AppSyncGraphQLResolver();
*
* app.batchResolver<{ id: number }>(async (events) => {
* // your business logic here
* const ids = events.map((event) => event.arguments.id);
* return ids.map((id) => ({
* id,
* title: 'Post Title',
* content: 'Post Content',
* }));
* });
*
* export const handler = async (event, context) =>
* app.resolve(event, context);
* ```
*
* The method works also as class method decorator, so you can use it like this:
*
* @example
Expand Down Expand Up @@ -88,6 +119,35 @@ class AppSyncGraphQLResolver extends Router {
* export const handler = lambda.handler.bind(lambda);
* ```
*
* @example
* ```ts
* import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
* import type { AppSyncResolverEvent } from 'aws-lambda';
*
* const app = new AppSyncGraphQLResolver();
*
* class Lambda {
* ⁣@app.batchResolver({ fieldName: 'getPosts', typeName: 'Query' })
* async getPosts(events: AppSyncResolverEvent<{ id: number }>[]) {
* // your business logic here
* const ids = events.map((event) => event.arguments.id);
* return ids.map((id) => ({
* id,
* title: 'Post Title',
* content: 'Post Content',
* }));
* }
*
* async handler(event, context) {
* return app.resolve(event, context, {
* scope: this, // bind decorated methods to the class instance
* });
* }
* }
*
* const lambda = new Lambda();
* export const handler = lambda.handler.bind(lambda);
* ```
* @param event - The incoming event, which may be an AppSync GraphQL event or an array of events.
* @param context - The AWS Lambda context object.
* @param options - Optional parameters for the resolver, such as the scope of the handler.
Expand All @@ -98,27 +158,185 @@ class AppSyncGraphQLResolver extends Router {
options?: ResolveOptions
): Promise<unknown> {
if (Array.isArray(event)) {
this.logger.warn('Batch resolver is not implemented yet');
return;
if (event.some((e) => !isAppSyncGraphQLEvent(e))) {
this.logger.warn(
'Received a batch event that is not compatible with this resolver'
);
return;
}
return this.#withErrorHandling(
() => this.#executeBatchResolvers(event, context, options),
event[0]
);
}
if (!isAppSyncGraphQLEvent(event)) {
this.logger.warn(
'Received an event that is not compatible with this resolver'
);
return;
}

return this.#withErrorHandling(
() => this.#executeSingleResolver(event, context, options),
event
);
}

/**
* Executes the provided asynchronous function with error handling.
* If the function throws an error, it delegates error processing to `#handleError`
* and returns the formatted error response.
*
* @param fn - A function returning a Promise to be executed with error handling.
* @param event - The AppSync resolver event (single or first of batch).
*/
async #withErrorHandling(
fn: () => Promise<unknown>,
event: AppSyncResolverEvent<Record<string, unknown>>
): Promise<unknown> {
try {
return await this.#executeSingleResolver(event, context, options);
return await fn();
} catch (error) {
this.logger.error(
`An error occurred in handler ${event.info.fieldName}`,
error
return this.#handleError(
error,
`An error occurred in handler ${event.info.fieldName}`
);
if (error instanceof ResolverNotFoundException) throw error;
return this.#formatErrorResponse(error);
}
}

/**
* Handles errors encountered during resolver execution.
*
* Logs the provided error message and error object. If the error is an instance of
* `InvalidBatchResponseException` or `ResolverNotFoundException`, it is re-thrown.
* Otherwise, the error is formatted into a response using `#formatErrorResponse`.
*
* @param error - The error object to handle.
* @param errorMessage - A descriptive message to log alongside the error.
* @throws InvalidBatchResponseException | ResolverNotFoundException
*/
#handleError(error: unknown, errorMessage: string) {
this.logger.error(errorMessage, error);
if (error instanceof InvalidBatchResponseException) throw error;
if (error instanceof ResolverNotFoundException) throw error;
return this.#formatErrorResponse(error);
}

/**
* Executes batch resolvers for multiple AppSync GraphQL events.
*
* This method processes an array of AppSync resolver events as a batch operation.
* It looks up the appropriate batch resolver from the registry using the field name
* and parent type name from the first event, then delegates to the batch resolver
* if found.
*
* @param events - Array of AppSync resolver events to process as a batch
* @param context - AWS Lambda context object
* @param options - Optional resolve options for customizing resolver behavior
* @throws {ResolverNotFoundException} When no batch resolver is registered for the given type and field combination
*/
async #executeBatchResolvers(
events: AppSyncResolverEvent<Record<string, unknown>>[],
context: Context,
options?: ResolveOptions
): Promise<unknown[]> {
const { fieldName, parentTypeName: typeName } = events[0].info;
const batchHandlerOptions = this.batchResolverRegistry.resolve(
typeName,
fieldName
);

if (batchHandlerOptions) {
return await this.#callBatchResolver(
events,
context,
batchHandlerOptions,
options
);
}

throw new ResolverNotFoundException(
`No batch resolver found for ${typeName}-${fieldName}`
);
}

/**
* Handles batch invocation of AppSync GraphQL resolvers with support for aggregation and error handling.
*
* @param events - An array of AppSyncResolverEvent objects representing the batch of incoming events.
* @param context - The Lambda context object.
* @param options - Route handler options, including the handler function, aggregation, and error handling flags.
* @param resolveOptions - Optional resolve options, such as custom scope for handler invocation.
*
* @throws {InvalidBatchResponseException} If the aggregate handler does not return an array.
*
* @remarks
* - If `aggregate` is true, invokes the handler once with the entire batch and expects an array response.
* - If `throwOnError` is true, errors are propagated and will cause the function to throw.
* - If `throwOnError` is false, errors are logged and `null` is appended for failed events, allowing graceful degradation.
*/
async #callBatchResolver(
events: AppSyncResolverEvent<Record<string, unknown>>[],
context: Context,
options: RouteHandlerOptions<Record<string, unknown>, boolean, boolean>,
resolveOptions?: ResolveOptions
): Promise<unknown[]> {
const { aggregate, throwOnError } = options;
this.logger.debug(
`Aggregate flag aggregate=${aggregate} & graceful error handling flag throwOnError=${throwOnError}`
);

if (aggregate) {
const response = await (
options.handler as BatchResolverAggregateHandlerFn
).apply(resolveOptions?.scope ?? this, [
events,
{ event: events, context },
]);

if (!Array.isArray(response)) {
throw new InvalidBatchResponseException(
'The response must be an array when using batch resolvers'
);
}

return response;
}

const handler = options.handler as BatchResolverHandlerFn;
const results: unknown[] = [];

if (throwOnError) {
for (const event of events) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should give users the option to do these in parallel with Promise.all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When raiseOnError is true, we stop on the first error. But if we use Promise.all, I believe we won’t be able to stop at the first error, since the promises will run in parallel.

However, it should be possible when raiseOnError is false. Also, do we want to give the user the option for this or just do parallel processing by default, similar to what has been done in AppSyncEventResolver

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way Promise.all works is that the first error causes all in-flight promises to be rejected so I think that's still OK. But we would need to use Promise.allSettled if raiseOnError was false. Good point about following what's done in AppSyncEventResolver, I think we should do that.

Small nit about the raiseOnError variable name, would throwOnError not be more appropriate. The word raise is more a Python thing. to my mind.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout about throwOn..., we use this in several other places in this version of Powertools for AWS:

As well as probably others, so +1 on changing it to this.


Regarding the Promise.all, if I remember correctly we didn't use it in the other Event Handler because even though the first rejected promises causes the "overall" promise to also be rejected, it doesn't abort the other in-flight promises, for example:

const promise1 = new Promise((resolve, reject) => {
  setTimeout(resolve, 2000, 'resolve1');
}).then((a) => {
  console.log('then1');
  return a;
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(reject, 1000, 'reject2');
}).then((a) => {
  console.log('then2');
  return a;
});

const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 3000, 'resolve3');
}).then((a) => {
  console.log('then3');
  return a;
});

Promise.all([promise1, promise2, promise3])
  .then((values) => {
    console.log('then', values);
  })
  .catch((err) => {
    console.log('catch', err);
  });

results in:

catch reject2
then1
then3

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, that makes sense!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed raiseOnError to throwOnError

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think we should run the event processing in parallel if throwOnError is false although not with Promise.all but rather Promise.allSettled.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, theoretically, we can use Promise.allSettled when throwOnError is false. I can't think of any obvious issues with it. @dreamorosi, any thoughts?

const result = await handler.apply(resolveOptions?.scope ?? this, [
event.arguments,
{ event, context },
]);
results.push(result);
}
return results;
}

for (let i = 0; i < events.length; i++) {
try {
const result = await handler.apply(resolveOptions?.scope ?? this, [
events[i].arguments,
{ event: events[i], context },
]);
results.push(result);
} catch (error) {
this.logger.error(error);
this.logger.debug(
`Failed to process event #${i + 1} from field '${events[i].info.fieldName}'`
);
// By default, we gracefully append `null` for any records that failed processing
results.push(null);
}
}

return results;
}

/**
* Executes the appropriate resolver for a given AppSync GraphQL event.
*
Expand All @@ -143,10 +361,10 @@ class AppSyncGraphQLResolver extends Router {
fieldName
);
if (resolverHandlerOptions) {
return resolverHandlerOptions.handler.apply(options?.scope ?? this, [
event.arguments,
{ event, context },
]);
return (resolverHandlerOptions.handler as ResolverHandler).apply(
options?.scope ?? this,
[event.arguments, { event, context }]
);
}

throw new ResolverNotFoundException(
Expand Down
17 changes: 13 additions & 4 deletions packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ class RouteHandlerRegistry {
/**
* A map of registered route handlers, keyed by their type & field name.
*/
protected readonly resolvers: Map<string, RouteHandlerOptions> = new Map();
protected readonly resolvers: Map<
string,
RouteHandlerOptions<Record<string, unknown>, boolean, boolean>
> = new Map();
/**
* A logger instance to be used for logging debug and warning messages.
*/
Expand All @@ -34,8 +37,10 @@ class RouteHandlerRegistry {
* @param options.typeName - The name of the GraphQL type to be registered
*
*/
public register(options: RouteHandlerOptions): void {
const { fieldName, handler, typeName } = options;
public register(
options: RouteHandlerOptions<Record<string, unknown>, boolean, boolean>
): void {
const { fieldName, handler, typeName, throwOnError, aggregate } = options;
this.#logger.debug(`Adding resolver for field ${typeName}.${fieldName}`);
const cacheKey = this.#makeKey(typeName, fieldName);
if (this.resolvers.has(cacheKey)) {
Expand All @@ -47,6 +52,8 @@ class RouteHandlerRegistry {
fieldName,
handler,
typeName,
throwOnError,
aggregate,
});
}

Expand All @@ -59,7 +66,9 @@ class RouteHandlerRegistry {
public resolve(
typeName: string,
fieldName: string
): RouteHandlerOptions | undefined {
):
| RouteHandlerOptions<Record<string, unknown>, boolean, boolean>
| undefined {
this.#logger.debug(
`Looking for resolver for type=${typeName}, field=${fieldName}`
);
Expand Down
Loading