diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 9c2679dcea..f6a0428e60 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -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'; @@ -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 @@ -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. @@ -98,8 +158,16 @@ class AppSyncGraphQLResolver extends Router { options?: ResolveOptions ): Promise { 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( @@ -107,18 +175,168 @@ class AppSyncGraphQLResolver extends Router { ); 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, + event: AppSyncResolverEvent> + ): Promise { 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>[], + context: Context, + options?: ResolveOptions + ): Promise { + 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>[], + context: Context, + options: RouteHandlerOptions, boolean, boolean>, + resolveOptions?: ResolveOptions + ): Promise { + 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) { + 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. * @@ -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( diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 7113ccfd80..a33f7a62bf 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -15,7 +15,10 @@ class RouteHandlerRegistry { /** * A map of registered route handlers, keyed by their type & field name. */ - protected readonly resolvers: Map = new Map(); + protected readonly resolvers: Map< + string, + RouteHandlerOptions, boolean, boolean> + > = new Map(); /** * A logger instance to be used for logging debug and warning messages. */ @@ -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, 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)) { @@ -47,6 +52,8 @@ class RouteHandlerRegistry { fieldName, handler, typeName, + throwOnError, + aggregate, }); } @@ -59,7 +66,9 @@ class RouteHandlerRegistry { public resolve( typeName: string, fieldName: string - ): RouteHandlerOptions | undefined { + ): + | RouteHandlerOptions, boolean, boolean> + | undefined { this.#logger.debug( `Looking for resolver for type=${typeName}, field=${fieldName}` ); diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index b1d0feb4e6..ce70618726 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -4,6 +4,8 @@ import { isDevMode, } from '@aws-lambda-powertools/commons/utils/env'; import type { + BatchResolverHandler, + GraphQlBatchRouteOptions, GraphQlRouteOptions, GraphQlRouterOptions, ResolverHandler, @@ -18,6 +20,10 @@ class Router { * A map of registered routes for all GraphQL events, keyed by their fieldNames. */ protected readonly resolverRegistry: RouteHandlerRegistry; + /** + * A map of registered routes for GraphQL batch events, keyed by their fieldNames. + */ + protected readonly batchResolverRegistry: RouteHandlerRegistry; /** * A logger instance to be used for logging debug, warning, and error messages. * @@ -42,6 +48,9 @@ class Router { this.resolverRegistry = new RouteHandlerRegistry({ logger: this.logger, }); + this.batchResolverRegistry = new RouteHandlerRegistry({ + logger: this.logger, + }); this.isDev = isDevMode(); } @@ -74,6 +83,13 @@ class Router { * typeName: 'Mutation' * }); * + * // Register a batch resolver + * app.batchResolver<{ id: number }>(async (events) => { + * return events.map(event => ({ id: event.arguments.id, data: 'processed' })); + * }, { + * fieldName: 'getPosts', + * }); + * * export const handler = async (event, context) => * app.resolve(event, context); * ``` @@ -102,7 +118,7 @@ class Router { * @example * ```ts * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; - * + * import type { AppSyncResolverEvent } from 'aws-lambda'; * const app = new AppSyncGraphQLResolver(); * * class Lambda { @@ -112,6 +128,12 @@ class Router { * return payload; * } * + * @app.batchResolver({ fieldName: 'getPosts' }) + * async handleGetPosts(events: AppSyncResolverEvent<{ id: number }>[]) { + * // Process batch of events + * return events.map(event => ({ id: event.arguments.id, data: 'processed' })); + * } + * * async handler(event, context) { * return app.resolve(event, context, { * scope: this, // bind decorated methods to the class instance @@ -318,6 +340,612 @@ class Router { return descriptor; }; } + + /** + * Register a batch resolver function for GraphQL events that support batching. + * + * Registers a handler for a specific GraphQL field that can process multiple requests in a batch. + * The handler will be invoked when requests are made for the specified field, and can either + * process requests individually or aggregate them for batch processing. + * + * By default, the handler will receive all batch events at once as an array and you are responsible for processing + * them and returning an array of results. The first parameter is an array of events, while the second parameter + * provides the original event array and context. + * + * If your function throws an error, we catch it and format the error response to be sent back to AppSync. This helps + * the client understand what went wrong and handle the error accordingly. + * + * It's important to note that if your function throws an error when processing in aggregate mode, the entire + * batch of events will be affected. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.batchResolver<{id: number}>(async (events) => { + * // Process all events in batch + * return events.map(event => ({ id: event.arguments.id, data: 'processed' })); + * }, { + * fieldName: 'getPosts' + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * **Process events individually** + * + * If you want to process each event individually instead of receiving all events at once, you can set the + * `aggregate` option to `false`. In this case, the handler will be called once for each event in the batch, + * similar to regular resolvers. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.batchResolver(async (args, { event, context }) => { + * // Process individual request + * return { id: args.id, data: 'processed' }; + * }, { + * fieldName: 'getPost', + * aggregate: false + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * When the handler is called, the first parameter contains the arguments from the GraphQL request, while the second + * parameter provides the original event and context, similar to regular resolvers. + * + * When `aggregate` is `false`, by default if one of the events in the batch throws an error, we catch it + * and append `null` for that specific event in the results array, allowing other events to be processed successfully. + * This provides graceful error handling where partial failures don't affect the entire batch. + * + * **Strict error handling** + * + * If you want stricter error handling when processing events individually, you can set the `throwOnError` option + * to `true`. In this case, if any event throws an error, the entire batch processing will stop and the error + * will be propagated. Note that `throwOnError` can only be used when `aggregate` is set to `false`. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.batchResolver(async (args, { event, context }) => { + * // Process individual request + * return { id: args.id, data: 'processed' }; + * }, { + * fieldName: 'getPost', + * aggregate: false, + * throwOnError: true + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * You can also specify the type of the arguments using generic type parameters for non-aggregated handlers: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver() + * + * app.batchResolver<{ postId: string }>(async (args, { event, context }) => { + * // args is typed as { postId: string } + * return { id: args.postId }; + * }, { + * fieldName: 'getPost', + * aggregate: false + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * As a decorator: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * class Lambda { + * ⁣@app.batchResolver({ fieldName: 'getPosts' }) + * async handleGetPosts(events) { + * // Process batch of events + * return events.map(event => ({ id: event.arguments.id, data: 'processed' })); + * } + * + * ⁣@app.batchResolver({ fieldName: 'getPost', aggregate: false }) + * async handleGetPost(args, { event, context }) { + * // Process individual request + * return { id: args.id, data: 'processed' }; + * } + * + * ⁣@app.batchResolver({ fieldName: 'getPost', aggregate: false, throwOnError: true }) + * async handleGetPostStrict(args, { event, context }) { + * // Process individual request with strict error handling + * return { id: args.id, data: 'processed' }; + * } + * + * 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 handler - The batch handler function to be called when events are received. + * @param options - Batch route options including the required fieldName and optional configuration. + * @param options.fieldName - The name of the field to register the handler for. + * @param options.typeName - The name of the GraphQL type to use for the resolver, defaults to `Query`. + * @param options.aggregate - Whether to aggregate multiple requests into a single handler call, defaults to `true`. + * @param options.throwOnError - Whether to raise errors when processing individual requests (only available when aggregate is false), defaults to `false`. + */ + public batchResolver< + TParams extends Record, + TSource = Record | null, + >( + handler: BatchResolverHandler, + options: GraphQlBatchRouteOptions + ): void; + public batchResolver< + TParams extends Record, + TSource = Record | null, + >( + handler: BatchResolverHandler, + options: GraphQlBatchRouteOptions + ): void; + public batchResolver( + options: GraphQlBatchRouteOptions + ): MethodDecorator; + public batchResolver< + TParams extends Record, + TSource = Record | null, + T extends boolean = true, + R extends boolean = false, + >( + handler: + | BatchResolverHandler + | GraphQlBatchRouteOptions, + options?: GraphQlBatchRouteOptions + ): MethodDecorator | undefined { + if (typeof handler === 'function') { + const batchResolverOptions = options as GraphQlBatchRouteOptions; + const { typeName = 'Query', fieldName } = batchResolverOptions; + this.batchResolverRegistry.register({ + fieldName, + handler: handler as BatchResolverHandler, + typeName, + aggregate: batchResolverOptions?.aggregate ?? true, + throwOnError: batchResolverOptions?.throwOnError ?? false, + }); + return; + } + + const batchResolverOptions = handler; + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const { typeName = 'Query', fieldName } = batchResolverOptions; + this.batchResolverRegistry.register({ + fieldName, + handler: descriptor?.value, + typeName, + aggregate: batchResolverOptions?.aggregate ?? true, + throwOnError: batchResolverOptions?.throwOnError ?? false, + }); + return descriptor; + }; + } + + /** + * Register a batch handler function for the `query` event. + * + * Registers a batch handler for a specific GraphQL Query field that can process multiple requests in a batch. + * The handler will be invoked when requests are made for the specified field in the Query type. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onBatchQuery<{ id: number }>('getPosts', async (events) => { + * // Process all events in batch + * return events.map(event => ({ id: event.arguments.id, data: 'processed' })); + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * By default, the handler will receive all batch events at once as an array and you are responsible for processing + * them and returning an array of results. The first parameter is an array of events, while the second parameter + * provides the original event array and context. + * + * If your function throws an error, we catch it and format the error response to be sent back to AppSync. This helps + * the client understand what went wrong and handle the error accordingly. + * + * It's important to note that if your function throws an error when processing in aggregate mode, the entire + * batch of events will be affected. + * + * **Process events individually** + * + * If you want to process each event individually instead of receiving all events at once, you can set the + * `aggregate` option to `false`. In this case, the handler will be called once for each event in the batch, + * similar to regular resolvers. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onBatchQuery('getPost', async (args, { event, context }) => { + * // Process individual request + * return { id: args.id, data: 'processed' }; + * }, { aggregate: false }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * When the handler is called, the first parameter contains the arguments from the GraphQL request, while the second + * parameter provides the original event and context, similar to regular resolvers. + * + * When `aggregate` is `false`, by default if one of the events in the batch throws an error, we catch it + * and append `null` for that specific event in the results array, allowing other events to be processed successfully. + * This provides graceful error handling where partial failures don't affect the entire batch. + * + * **Strict error handling** + * + * If you want stricter error handling when processing events individually, you can set the `throwOnError` option + * to `true`. In this case, if any event throws an error, the entire batch processing will stop and the error + * will be propagated. Note that `throwOnError` can only be used when `aggregate` is set to `false`. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onBatchQuery('getPost', async (args, { event, context }) => { + * // Process individual request + * return { id: args.id, data: 'processed' }; + * }, { aggregate: false, throwOnError: true }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * As a decorator: + * + * @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.onBatchQuery('getPosts') + * async handleGetPosts(events: AppSyncResolverEvent<{ id: number }>[]) { + * // Process batch of events + * return events.map(event => ({ id: event.arguments.id, data: 'processed' })); + * } + * + * ⁣@app.onBatchQuery('getPost', { aggregate: false }) + * async handleGetPost(args, { event, context }) { + * // Process individual request + * return { id: args.id, data: 'processed' }; + * } + * + * ⁣@app.onBatchQuery('getPost', { aggregate: false, throwOnError: true }) + * async handleGetPostStrict(args, { event, context }) { + * // Process individual request with strict error handling + * return { id: args.id, data: 'processed' }; + * } + * + * 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 fieldName - The name of the Query field to register the batch handler for. + * @param handler - The batch handler function to be called when events are received. + * @param options - Optional batch configuration including aggregate and throwOnError settings. + * @param options.aggregate - Whether to aggregate multiple requests into a single handler call, defaults to `true`. + * @param options.throwOnError - Whether to raise errors when processing individual requests (only available when aggregate is false), defaults to `false`. + */ + public onBatchQuery< + TParams extends Record, + TSource = Record | null, + >( + fieldName: string, + handler: BatchResolverHandler, + options?: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): void; + public onBatchQuery< + TParams extends Record, + TSource = Record | null, + >( + fieldName: string, + handler: BatchResolverHandler, + options?: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): void; + public onBatchQuery( + fieldName: string, + options: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): MethodDecorator; + public onBatchQuery( + fieldName: string, + options?: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): MethodDecorator; + public onBatchQuery< + TParams extends Record, + TSource = Record | null, + T extends boolean = true, + R extends boolean = false, + >( + fieldName: string, + handlerOrOptions?: + | BatchResolverHandler + | Omit, 'fieldName' | 'typeName'>, + options?: Omit, 'fieldName' | 'typeName'> + ): MethodDecorator | undefined { + if (typeof handlerOrOptions === 'function') { + this.batchResolverRegistry.register({ + fieldName, + handler: handlerOrOptions as BatchResolverHandler, + typeName: 'Query', + aggregate: options?.aggregate ?? true, + throwOnError: options?.throwOnError ?? false, + }); + + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.batchResolverRegistry.register({ + fieldName, + handler: descriptor?.value, + typeName: 'Query', + aggregate: handlerOrOptions?.aggregate ?? true, + throwOnError: handlerOrOptions?.throwOnError ?? false, + }); + + return descriptor; + }; + } + + /** + * Register a batch handler function for the `mutation` event. + * + * Registers a batch handler for a specific GraphQL Mutation field that can process multiple requests in a batch. + * The handler will be invoked when requests are made for the specified field in the Mutation type. + * + * By default, the handler will receive all batch events at once as an array and you are responsible for processing + * them and returning an array of results. The first parameter is an array of events, while the second parameter + * provides the original event array and context. + * + * If your function throws an error, we catch it and format the error response to be sent back to AppSync. + * + * It's important to note that if your function throws an error when processing in aggregate mode, the entire + * batch of events will be affected. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onBatchMutation<{ id: number }>('createPosts', async (events) => { + * // Process all events in batch + * return events.map(event => ({ id: event.arguments.id, status: 'created' })); + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * **Process events individually** + * + * If you want to process each event individually instead of receiving all events at once, you can set the + * `aggregate` option to `false`. In this case, the handler will be called once for each event in the batch, + * similar to regular resolvers. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onBatchMutation('createPost', async (args, { event, context }) => { + * // Process individual request + * return { id: args.id, status: 'created' }; + * }, { aggregate: false }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * When the handler is called, the first parameter contains the arguments from the GraphQL request, while the second + * parameter provides the original event and context, similar to regular resolvers. + * + * When `aggregate` is `false`, by default if one of the events in the batch throws an error, we catch it + * and append `null` for that specific event in the results array, allowing other events to be processed successfully. + * This provides graceful error handling where partial failures don't affect the entire batch. + * + * **Strict error handling** + * + * If you want stricter error handling when processing events individually, you can set the `throwOnError` option + * to `true`. In this case, if any event throws an error, the entire batch processing will stop and the error + * will be propagated. Note that `throwOnError` can only be used when `aggregate` is set to `false`. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onBatchMutation('createPost', async (args, { event, context }) => { + * // Process individual request + * return { id: args.id, status: 'created' }; + * }, { aggregate: false, throwOnError: true }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * As a decorator: + * + * @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.onBatchMutation('createPosts') + * async handleCreatePosts(events: AppSyncResolverEvent<{ id: number }>[]) { + * // Process batch of events + * return events.map(event => ({ id: event.arguments.id, status: 'created' })); + * } + * + * ⁣@app.onBatchMutation('createPost', { aggregate: false }) + * async handleCreatePost(args, { event, context }) { + * // Process individual request + * return { id: args.id, status: 'created' }; + * } + * + * ⁣@app.onBatchMutation('createPost', { aggregate: false, throwOnError: true }) + * async handleCreatePostStrict(args, { event, context }) { + * // Process individual request with strict error handling + * return { id: args.id, status: 'created' }; + * } + * + * 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 fieldName - The name of the Mutation field to register the batch handler for. + * @param handler - The batch handler function to be called when events are received. + * @param options - Optional batch configuration including aggregate and throwOnError settings. + * @param options.aggregate - Whether to aggregate multiple requests into a single handler call, defaults to `true`. + * @param options.throwOnError - Whether to raise errors when processing individual requests (only available when aggregate is false), defaults to `false`. + */ + public onBatchMutation< + TParams extends Record, + TSource = Record | null, + >( + fieldName: string, + handler: BatchResolverHandler, + options?: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): void; + public onBatchMutation< + TParams extends Record, + TSource = Record | null, + >( + fieldName: string, + handler: BatchResolverHandler, + options?: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): void; + public onBatchMutation( + fieldName: string, + options: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): MethodDecorator; + public onBatchMutation( + fieldName: string, + options?: Omit< + GraphQlBatchRouteOptions, + 'fieldName' | 'typeName' + > + ): MethodDecorator; + public onBatchMutation< + TParams extends Record, + TSource = Record | null, + T extends boolean = true, + R extends boolean = false, + >( + fieldName: string, + handlerOrOptions?: + | BatchResolverHandler + | Omit, 'fieldName' | 'typeName'>, + options?: Omit, 'fieldName' | 'typeName'> + ): MethodDecorator | undefined { + if (typeof handlerOrOptions === 'function') { + this.batchResolverRegistry.register({ + fieldName, + handler: handlerOrOptions as BatchResolverHandler, + typeName: 'Mutation', + aggregate: options?.aggregate ?? true, + throwOnError: options?.throwOnError ?? false, + }); + + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.batchResolverRegistry.register({ + fieldName, + handler: descriptor?.value, + typeName: 'Mutation', + aggregate: handlerOrOptions?.aggregate ?? true, + throwOnError: handlerOrOptions?.throwOnError ?? false, + }); + + return descriptor; + }; + } } export { Router }; diff --git a/packages/event-handler/src/appsync-graphql/errors.ts b/packages/event-handler/src/appsync-graphql/errors.ts index 73825cf2b7..e005499e78 100644 --- a/packages/event-handler/src/appsync-graphql/errors.ts +++ b/packages/event-handler/src/appsync-graphql/errors.ts @@ -8,4 +8,14 @@ class ResolverNotFoundException extends Error { } } -export { ResolverNotFoundException }; +/** + * Error thrown when the response from a batch resolver is invalid. + */ +class InvalidBatchResponseException extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'InvalidBatchResponseException'; + } +} + +export { ResolverNotFoundException, InvalidBatchResponseException }; diff --git a/packages/event-handler/src/appsync-graphql/index.ts b/packages/event-handler/src/appsync-graphql/index.ts index 40524b71aa..9fe91122f4 100644 --- a/packages/event-handler/src/appsync-graphql/index.ts +++ b/packages/event-handler/src/appsync-graphql/index.ts @@ -1,5 +1,8 @@ export { AppSyncGraphQLResolver } from './AppSyncGraphQLResolver.js'; -export { ResolverNotFoundException } from './errors.js'; +export { + ResolverNotFoundException, + InvalidBatchResponseException, +} from './errors.js'; export { awsDate, awsDateTime, diff --git a/packages/event-handler/src/appsync-graphql/utils.ts b/packages/event-handler/src/appsync-graphql/utils.ts index da8b4402a6..3acebb2b36 100644 --- a/packages/event-handler/src/appsync-graphql/utils.ts +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -12,9 +12,7 @@ import type { AppSyncResolverEvent } from 'aws-lambda'; const isAppSyncGraphQLEvent = ( event: unknown ): event is AppSyncResolverEvent> => { - if (typeof event !== 'object' || event === null || !isRecord(event)) { - return false; - } + if (!isRecord(event)) return false; return ( isRecord(event.arguments) && 'identity' in event && diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index d799df13e9..91d81a4a13 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -3,6 +3,64 @@ import type { AppSyncResolverEvent, Context } from 'aws-lambda'; import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; import type { Router } from '../appsync-graphql/Router.js'; +// #region BatchResolver fn + +type BatchResolverSyncHandlerFn< + TParams = Record, + TSource = Record | null, +> = ( + args: TParams, + options: { + event: AppSyncResolverEvent; + context: Context; + } +) => unknown; + +type BatchResolverHandlerFn< + TParams = Record, + TSource = Record | null, +> = ( + args: TParams, + options: { + event: AppSyncResolverEvent; + context: Context; + } +) => Promise; + +type BatchResolverAggregateHandlerFn< + TParams = Record, + TSource = Record | null, +> = ( + events: AppSyncResolverEvent[], + options: { + event: AppSyncResolverEvent[]; + context: Context; + } +) => Promise; + +type BatchResolverSyncAggregateHandlerFn< + TParams = Record, + TSource = Record | null, +> = ( + events: AppSyncResolverEvent[], + options: { + event: AppSyncResolverEvent[]; + context: Context; + } +) => unknown; + +type BatchResolverHandler< + TParams = Record, + TSource = Record | null, + T extends boolean | undefined = undefined, +> = T extends true + ? + | BatchResolverAggregateHandlerFn + | BatchResolverSyncAggregateHandlerFn + : + | BatchResolverHandlerFn + | BatchResolverSyncHandlerFn; + // #region Resolver fn type ResolverSyncHandlerFn> = ( @@ -46,11 +104,16 @@ type RouteHandlerRegistryOptions = { * @property fieldName - The name of the field to be registered * @property typeName - The name of the type to be registered */ -type RouteHandlerOptions> = { +type RouteHandlerOptions< + TParams, + T extends boolean, + R extends boolean, + TSource = Record | null, +> = { /** * The handler function to be called when the event is received */ - handler: ResolverHandler; + handler: BatchResolverHandler | ResolverHandler; /** * The field name of the event to be registered */ @@ -59,6 +122,16 @@ type RouteHandlerOptions> = { * The type name of the event to be registered */ typeName: string; + /** + * Whether the route handler will send all the events to the route handler at once or one by one + * @default true + */ + aggregate?: T; + /** + * Whether to raise an error if the handler fails + * @default false + */ + throwOnError?: R; }; // #region Router @@ -89,10 +162,30 @@ type GraphQlRouteOptions = { typeName?: string; }; +/** + * Options for configuring a batch GraphQL route handler. + * + * @template T - If `true`, the handler receives all events at once and `throwOnError` cannot be specified. + * If `false`, the handler is called for each event individually and `throwOnError` can be specified. + * Defaults to `true`. + * @template R - If `true`, errors thrown by the handler will be raised. Defaults to `false`. + */ +type GraphQlBatchRouteOptions< + T extends boolean | undefined = true, + R extends boolean | undefined = false, +> = GraphQlRouteOptions & + (T extends true + ? { aggregate?: T; throwOnError?: never } + : { aggregate?: T; throwOnError?: R }); + export type { RouteHandlerRegistryOptions, RouteHandlerOptions, GraphQlRouterOptions, GraphQlRouteOptions, + GraphQlBatchRouteOptions, ResolverHandler, + BatchResolverHandler, + BatchResolverHandlerFn, + BatchResolverAggregateHandlerFn, }; diff --git a/packages/event-handler/src/types/index.ts b/packages/event-handler/src/types/index.ts index c26f70b878..8a6189533e 100644 --- a/packages/event-handler/src/types/index.ts +++ b/packages/event-handler/src/types/index.ts @@ -11,6 +11,7 @@ export type { } from './appsync-events.js'; export type { + BatchResolverHandler, GraphQlRouteOptions, GraphQlRouterOptions, ResolverHandler, diff --git a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts index 6e6c2574f9..27bc862e00 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -1,8 +1,11 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import type { Context } from 'aws-lambda'; +import type { AppSyncResolverEvent, Context } from 'aws-lambda'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; -import { ResolverNotFoundException } from '../../../src/appsync-graphql/index.js'; +import { + InvalidBatchResponseException, + ResolverNotFoundException, +} from '../../../src/appsync-graphql/index.js'; import { onGraphqlEventFactory } from '../../helpers/factories.js'; describe('Class: AppSyncGraphQLResolver', () => { @@ -10,23 +13,6 @@ describe('Class: AppSyncGraphQLResolver', () => { vi.clearAllMocks(); }); - it('logs a warning and returns early if the event is batched', async () => { - // Prepare - const app = new AppSyncGraphQLResolver({ logger: console }); - - // Act - const result = await app.resolve( - [onGraphqlEventFactory('getPost', 'Query')], - context - ); - - // Assess - expect(console.warn).toHaveBeenCalledWith( - 'Batch resolver is not implemented yet' - ); - expect(result).toBeUndefined(); - }); - it('logs a warning and returns early if the event is not compatible', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); @@ -67,6 +53,21 @@ describe('Class: AppSyncGraphQLResolver', () => { expect(console.error).toHaveBeenCalled(); }); + it('throws error if there are no handlers for batch events', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act && Assess + await expect( + app.resolve([onGraphqlEventFactory('relatedPosts', 'Query')], context) + ).rejects.toThrow( + new ResolverNotFoundException( + 'No batch resolver found for Query-relatedPosts' + ) + ); + expect(console.error).toHaveBeenCalled(); + }); + it('returns the response of the `Query` handler', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); @@ -265,6 +266,107 @@ describe('Class: AppSyncGraphQLResolver', () => { }); }); + it('preserves the scope when using `batchResolver` decorator', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + class Lambda { + public scope = 'scoped'; + + @app.batchResolver({ fieldName: 'batchGet' }) + public async handleBatchGet( + events: AppSyncResolverEvent<{ id: number }>[] + ) { + const ids = events.map((event) => event.arguments.id); + return ids.map((id) => ({ + id, + scope: `${this.scope} id=${id}`, + })); + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context, { scope: this }); + } + } + const lambda = new Lambda(); + const handler = lambda.handler.bind(lambda); + + // Act + const result = await handler( + [ + onGraphqlEventFactory('batchGet', 'Query', { id: 1 }), + onGraphqlEventFactory('batchGet', 'Query', { id: 2 }), + ], + context + ); + + // Assess + expect(result).toEqual([ + { id: 1, scope: 'scoped id=1' }, + { id: 2, scope: 'scoped id=2' }, + ]); + }); + + it.each([ + { + throwOnError: true, + description: 'throwOnError=true', + }, + { + throwOnError: false, + description: 'throwOnError=false', + }, + ])( + 'preserves the scope when using `batchResolver` decorator when aggregate=false and $description', + async ({ throwOnError }) => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + class Lambda { + public scope = 'scoped'; + + @app.batchResolver({ + fieldName: 'batchGet', + throwOnError, + aggregate: false, + }) + public async handleBatchGet({ id }: { id: string }) { + return { + id, + scope: `${this.scope} id=${id} throwOnError=${throwOnError} aggregate=false`, + }; + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context, { scope: this }); + } + } + const lambda = new Lambda(); + const handler = lambda.handler.bind(lambda); + + // Act + const result = await handler( + [ + onGraphqlEventFactory('batchGet', 'Query', { id: 1 }), + onGraphqlEventFactory('batchGet', 'Query', { id: 2 }), + ], + context + ); + + // Assess + expect(result).toEqual([ + { + id: 1, + scope: `scoped id=1 throwOnError=${throwOnError} aggregate=false`, + }, + { + id: 2, + scope: `scoped id=2 throwOnError=${throwOnError} aggregate=false`, + }, + ]); + } + ); + it('emits debug message when AWS_LAMBDA_LOG_LEVEL is set to DEBUG', async () => { // Prepare vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); @@ -346,4 +448,196 @@ describe('Class: AppSyncGraphQLResolver', () => { }); } ); + + it('logs a warning and returns early if one of the batch events is not compatible', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + app.batchResolver(vi.fn(), { + fieldName: 'batchGet', + typeName: 'Query', + aggregate: true, + }); + + // Act + const result = await app.resolve( + [ + onGraphqlEventFactory('batchGet', 'Query', { id: '1' }), + { + key: 'notCompatible', + type: 'unknown', + }, + ], + context + ); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Received a batch event that is not compatible with this resolver' + ); + expect(result).toBeUndefined(); + }); + + it.each([ + { + aggregate: true, + description: 'aggregate=true', + setupHandler: (handler: ReturnType) => { + handler.mockResolvedValue([ + { id: '1', value: 'A' }, + { id: '2', value: 'B' }, + ]); + }, + }, + { + aggregate: false, + description: 'aggregate=false and throwOnError=true', + setupHandler: (handler: ReturnType) => { + handler + .mockResolvedValueOnce({ id: '1', value: 'A' }) + .mockResolvedValueOnce({ id: '2', value: 'B' }); + }, + }, + ])( + 'registers a batch resolver via direct function call and invokes it ($description)', + async ({ aggregate, setupHandler }) => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + const handler = vi.fn(); + setupHandler(handler); + + if (aggregate) { + app.batchResolver(handler, { + fieldName: 'batchGet', + typeName: 'Query', + aggregate: true, + }); + } else { + app.batchResolver(handler, { + fieldName: 'batchGet', + typeName: 'Query', + aggregate: false, + throwOnError: true, + }); + } + + const events = [ + onGraphqlEventFactory('batchGet', 'Query', { id: '1' }), + onGraphqlEventFactory('batchGet', 'Query', { id: '2' }), + ]; + + // Act + const result = await app.resolve(events, context); + + // Assess + if (aggregate) { + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(events, { + event: events, + context, + }); + } else { + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith(1, events[0].arguments, { + event: events[0], + context, + }); + expect(handler).toHaveBeenNthCalledWith(2, events[1].arguments, { + event: events[1], + context, + }); + } + expect(result).toEqual([ + { id: '1', value: 'A' }, + { id: '2', value: 'B' }, + ]); + } + ); + + it('returns null for failed records when aggregate=false', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + const handler = vi + .fn() + .mockResolvedValueOnce({ id: '1', value: 'A' }) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ id: '3', value: 'C' }); + + app.batchResolver(handler, { + fieldName: 'batchGet', + typeName: 'Query', + aggregate: false, + }); + const events = [ + onGraphqlEventFactory('batchGet', 'Query', { id: '1' }), + onGraphqlEventFactory('batchGet', 'Query', { id: '2' }), + onGraphqlEventFactory('batchGet', 'Query', { id: '3' }), + ]; + + // Act + const result = await app.resolve(events, context); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 4, + "Failed to process event #2 from field 'batchGet'" + ); + expect(result).toEqual([ + { id: '1', value: 'A' }, + null, + { id: '3', value: 'C' }, + ]); + }); + + it('stops on first error when aggregate=false and throwOnError=true', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + const handler = vi + .fn() + .mockResolvedValueOnce({ id: '1', value: 'A' }) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ id: '3', value: 'C' }); + app.batchResolver(handler, { + fieldName: 'batchGet', + typeName: 'Query', + aggregate: false, + throwOnError: true, + }); + const events = [ + onGraphqlEventFactory('batchGet', 'Query', { id: '1' }), + onGraphqlEventFactory('batchGet', 'Query', { id: '2' }), + onGraphqlEventFactory('batchGet', 'Query', { id: '3' }), + ]; + + // Act + const result = await app.resolve(events, context); + + // Assess + expect(handler).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + error: 'Error - fail', + }); + }); + + it('throws error if aggregate handler does not return an array', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + const handler = vi.fn().mockResolvedValue({ id: '1', value: 'A' }); + app.batchResolver(handler, { + fieldName: 'batchGet', + typeName: 'Query', + aggregate: true, + }); + + // Act && Assess + await expect( + app.resolve( + [onGraphqlEventFactory('batchGet', 'Query', { id: '1' })], + context + ) + ).rejects.toThrow( + new InvalidBatchResponseException( + 'The response must be an array when using batch resolvers' + ) + ); + }); }); diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts index 215a05445d..4de557fd63 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -4,7 +4,10 @@ import type { RouteHandlerOptions } from '../../../src/types/appsync-graphql.js' describe('Class: RouteHandlerRegistry', () => { class MockRouteHandlerRegistry extends RouteHandlerRegistry { - public declare resolvers: Map; + public declare resolvers: Map< + string, + RouteHandlerOptions, boolean, boolean> + >; } const getRegistry = () => new MockRouteHandlerRegistry({ logger: console }); diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts index 29c6a02aae..6dcd7dca26 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -122,6 +122,101 @@ describe('Class: Router', () => { ]); }); + it('registers batch resolvers using the functional approach', () => { + // Prepare + const app = new Router({ logger: console }); + const getPosts = vi.fn(() => [{ id: 1 }]); + const addPosts = vi.fn(async () => [{ id: 2 }]); + + // Act + app.onBatchQuery('getPosts', getPosts); + app.onBatchMutation('addPosts', addPosts); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Query.getPosts' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding resolver for field Mutation.addPosts' + ); + }); + + it('registers batch resolvers using the decorator pattern', () => { + // Prepare + const app = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @app.onBatchQuery('getPosts') + public getPosts() { + return `${this.prop} batchQuery`; + } + + @app.onBatchMutation('addPosts') + public addPosts() { + return `${this.prop} batchMutation`; + } + } + const lambda = new Lambda(); + const res1 = lambda.getPosts(); + const res2 = lambda.addPosts(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Query.getPosts' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding resolver for field Mutation.addPosts' + ); + + // verify that class scope is preserved after decorating + expect(res1).toBe('value batchQuery'); + expect(res2).toBe('value batchMutation'); + }); + + it('registers nested batch resolvers using the decorator pattern', () => { + // Prepare + const app = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @app.onBatchQuery('listLocations') + @app.onBatchQuery('locations') + public getLocations() { + return [ + { + name: 'Batch Location 1', + description: 'Batch Description 1', + }, + ]; + } + } + const lambda = new Lambda(); + const response = lambda.getLocations(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Query.locations' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding resolver for field Query.listLocations' + ); + + expect(response).toEqual([ + { name: 'Batch Location 1', description: 'Batch Description 1' }, + ]); + }); + it('uses a default logger with only warnings if none is provided', () => { // Prepare const app = new Router();