diff --git a/docs/features/event-handler/appsync-graphql.md b/docs/features/event-handler/appsync-graphql.md index e782593fba..f99e40e5e0 100644 --- a/docs/features/event-handler/appsync-graphql.md +++ b/docs/features/event-handler/appsync-graphql.md @@ -148,6 +148,38 @@ You can access the original Lambda event or context for additional information. 1. The `event` parameter contains the original AppSync event and has type `AppSyncResolverEvent` from the `@types/aws-lambda`. +### Exception Handling + +You can use the **`exceptionHandler`** method to handle any exception. This allows you to handle common errors outside your resolver and return a custom response. + +The **`exceptionHandler`** method also supports passing an array of exceptions that you wish to handle with a single handler. + +You can use an AppSync JavaScript resolver or a VTL response mapping template to detect these custom responses and forward them to the client gracefully. + +=== "Exception Handling" + + ```typescript hl_lines="11-18 21-23" + --8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandling.ts" + ``` + +=== "APPSYNC JS Resolver" + + ```js hl_lines="11-13" + --8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandlingResolver.js" + ``` + +=== "VTL Response Mapping Template" + + ```velocity hl_lines="1-3" + --8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponseMapping.vtl" + ``` + +=== "Exception Handling response" + + ```json hl_lines="11 20" + --8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponse.json" + ``` + ### Logging By default, the utility uses the global `console` logger and emits only warnings and errors. diff --git a/examples/snippets/event-handler/appsync-graphql/exceptionHandling.ts b/examples/snippets/event-handler/appsync-graphql/exceptionHandling.ts new file mode 100644 index 0000000000..7741e2f1c6 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/exceptionHandling.ts @@ -0,0 +1,27 @@ +import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { AssertionError } from 'assert'; +import type { Context } from 'aws-lambda'; + +const logger = new Logger({ + serviceName: 'MyService', +}); +const app = new AppSyncGraphQLResolver({ logger }); + +app.exceptionHandler(AssertionError, async (error) => { + return { + error: { + message: error.message, + type: error.name, + }, + }; +}); + +app.onQuery('createSomething', async () => { + throw new AssertionError({ + message: 'This is an assertion Error', + }); +}); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResolver.js b/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResolver.js new file mode 100644 index 0000000000..ceed023c26 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResolver.js @@ -0,0 +1,15 @@ +import { util } from '@aws-appsync/utils'; + +export function request(ctx) { + return { + operation: 'Invoke', + payload: ctx, + }; +} + +export function response(ctx) { + if (ctx.result.error) { + return util.error(ctx.result.error.message, ctx.result.error.type); + } + return ctx.result; +} diff --git a/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponse.json b/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponse.json new file mode 100644 index 0000000000..77c248e2f3 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponse.json @@ -0,0 +1,23 @@ +{ + "data": { + "createSomething": null + }, + "errors": [ + { + "path": [ + "createSomething" + ], + "data": null, + "errorType": "AssertionError", + "errorInfo": null, + "locations": [ + { + "line": 2, + "column": 3, + "sourceName": null + } + ], + "message": "This is an assertion Error" + } + ] +} \ No newline at end of file diff --git a/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponseMapping.vtl b/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponseMapping.vtl new file mode 100644 index 0000000000..db3ee9f21d --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponseMapping.vtl @@ -0,0 +1,5 @@ +#if (!$util.isNull($ctx.result.error)) + $util.error($ctx.result.error.message, $ctx.result.error.type) +#end + +$utils.toJson($ctx.result) \ No newline at end of file diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index f6a0428e60..cf4e71fdfb 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -166,7 +166,8 @@ class AppSyncGraphQLResolver extends Router { } return this.#withErrorHandling( () => this.#executeBatchResolvers(event, context, options), - event[0] + event[0], + options ); } if (!isAppSyncGraphQLEvent(event)) { @@ -178,7 +179,8 @@ class AppSyncGraphQLResolver extends Router { return this.#withErrorHandling( () => this.#executeSingleResolver(event, context, options), - event + event, + options ); } @@ -189,17 +191,20 @@ class AppSyncGraphQLResolver extends Router { * * @param fn - A function returning a Promise to be executed with error handling. * @param event - The AppSync resolver event (single or first of batch). + * @param options - Optional resolve options for customizing resolver behavior. */ async #withErrorHandling( fn: () => Promise, - event: AppSyncResolverEvent> + event: AppSyncResolverEvent>, + options?: ResolveOptions ): Promise { try { return await fn(); } catch (error) { - return this.#handleError( + return await this.#handleError( error, - `An error occurred in handler ${event.info.fieldName}` + `An error occurred in handler ${event.info.fieldName}`, + options ); } } @@ -209,16 +214,39 @@ class AppSyncGraphQLResolver extends Router { * * Logs the provided error message and error object. If the error is an instance of * `InvalidBatchResponseException` or `ResolverNotFoundException`, it is re-thrown. + * Checks for registered exception handlers and calls them if available. * 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. + * @param options - Optional resolve options for customizing resolver behavior. * @throws InvalidBatchResponseException | ResolverNotFoundException */ - #handleError(error: unknown, errorMessage: string) { + async #handleError( + error: unknown, + errorMessage: string, + options?: ResolveOptions + ): Promise { this.logger.error(errorMessage, error); if (error instanceof InvalidBatchResponseException) throw error; if (error instanceof ResolverNotFoundException) throw error; + if (error instanceof Error) { + const exceptionHandler = this.exceptionHandlerRegistry.resolve(error); + if (exceptionHandler) { + try { + this.logger.debug( + `Calling exception handler for error: ${error.name}` + ); + return await exceptionHandler.apply(options?.scope ?? this, [error]); + } catch (handlerError) { + this.logger.error( + `Exception handler for ${error.name} threw an error`, + handlerError + ); + } + } + } + return this.#formatErrorResponse(error); } diff --git a/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts new file mode 100644 index 0000000000..85638aed2c --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts @@ -0,0 +1,93 @@ +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import type { + ErrorClass, + ExceptionHandler, + ExceptionHandlerOptions, + ExceptionHandlerRegistryOptions, +} from '../types/appsync-graphql.js'; + +/** + * Registry for storing exception handlers for GraphQL resolvers in AWS AppSync GraphQL API's. + */ +class ExceptionHandlerRegistry { + /** + * A map of registered exception handlers, keyed by their error class name. + */ + protected readonly handlers: Map = new Map(); + /** + * A logger instance to be used for logging debug and warning messages. + */ + readonly #logger: Pick; + + public constructor(options: ExceptionHandlerRegistryOptions) { + this.#logger = options.logger; + } + + /** + * Registers an exception handler for one or more error classes. + * + * If a handler for the given error class is already registered, it will be replaced and a warning will be logged. + * + * @param options - The options containing the error class(es) and their associated handler. + * @param options.error - A single error class or an array of error classes to handle. + * @param options.handler - The exception handler function that will be invoked when the error occurs. + */ + public register(options: ExceptionHandlerOptions): void { + const { error, handler } = options; + const errors = Array.isArray(error) ? error : [error]; + + for (const err of errors) { + this.registerErrorHandler(err, handler as ExceptionHandler); + } + } + + /** + * Registers a error handler for a specific error class. + * + * @param errorClass - The error class to register the handler for. + * @param handler - The exception handler function. + */ + private registerErrorHandler( + errorClass: ErrorClass, + handler: ExceptionHandler + ): void { + const errorName = errorClass.name; + + this.#logger.debug(`Adding exception handler for error class ${errorName}`); + + if (this.handlers.has(errorName)) { + this.#logger.warn( + `An exception handler for error class '${errorName}' is already registered. The previous handler will be replaced.` + ); + } + + this.handlers.set(errorName, { + error: errorClass, + handler, + }); + } + + /** + * Resolves and returns the appropriate exception handler for a given error instance. + * + * This method attempts to find a registered exception handler based on the error class name. + * If a matching handler is found, it is returned; otherwise, `null` is returned. + * + * @param error - The error instance for which to resolve an exception handler. + */ + public resolve(error: Error): ExceptionHandler | null { + const errorName = error.name; + this.#logger.debug(`Looking for exception handler for error: ${errorName}`); + + const handlerOptions = this.handlers.get(errorName); + if (handlerOptions) { + this.#logger.debug(`Found exact match for error class: ${errorName}`); + return handlerOptions.handler; + } + + this.#logger.debug(`No exception handler found for error: ${errorName}`); + return null; + } +} + +export { ExceptionHandlerRegistry }; diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 57a243650b..29745e2663 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -5,11 +5,14 @@ import { } from '@aws-lambda-powertools/commons/utils/env'; import type { BatchResolverHandler, + ErrorClass, + ExceptionHandler, GraphQlBatchRouteOptions, GraphQlRouteOptions, GraphQlRouterOptions, ResolverHandler, } from '../types/appsync-graphql.js'; +import { ExceptionHandlerRegistry } from './ExceptionHandlerRegistry.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; /** @@ -24,6 +27,10 @@ class Router { * A map of registered routes for GraphQL batch events, keyed by their fieldNames. */ protected readonly batchResolverRegistry: RouteHandlerRegistry; + /** + * A map of registered exception handlers for handling errors in GraphQL resolvers. + */ + protected readonly exceptionHandlerRegistry: ExceptionHandlerRegistry; /** * A logger instance to be used for logging debug, warning, and error messages. * @@ -51,6 +58,9 @@ class Router { this.batchResolverRegistry = new RouteHandlerRegistry({ logger: this.logger, }); + this.exceptionHandlerRegistry = new ExceptionHandlerRegistry({ + logger: this.logger, + }); this.isDev = isDevMode(); } @@ -946,6 +956,110 @@ class Router { return descriptor; }; } + + /** + * Register an exception handler for a specific error class. + * + * Registers a handler for a specific error class that can be thrown by GraphQL resolvers. + * The handler will be invoked when an error of the specified class is thrown from any + * resolver function. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * import { AssertionError } from 'assert'; + * + * const app = new AppSyncGraphQLResolver(); + * + * // Register an exception handler for AssertionError + * app.exceptionHandler(AssertionError, async (error) => { + * return { + * error: { + * message: error.message, + * type: error.name + * } + * }; + * }); + * + * // Register a resolver that might throw an AssertionError + * app.onQuery('createSomething', async () => { + * throw new AssertionError({ + * message: 'This is an assertion Error', + * }); + * }); + * + * 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 { AssertionError } from 'assert'; + * + * const app = new AppSyncGraphQLResolver(); + * + * class Lambda { + * ⁣@app.exceptionHandler(AssertionError) + * async handleAssertionError(error: AssertionError) { + * return { + * error: { + * message: error.message, + * type: error.name + * } + * }; + * } + * + * ⁣@app.onQuery('getUser') + * async getUser() { + * throw new AssertionError({ + * message: 'This is an assertion Error', + * }); + * } + * + * 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 error - The error class to handle. + * @param handler - The handler function to be called when the error is caught. + */ + public exceptionHandler( + error: ErrorClass | ErrorClass[], + handler: ExceptionHandler + ): void; + public exceptionHandler( + error: ErrorClass | ErrorClass[] + ): MethodDecorator; + public exceptionHandler( + error: ErrorClass | ErrorClass[], + handler?: ExceptionHandler + ): MethodDecorator | undefined { + if (typeof handler === 'function') { + this.exceptionHandlerRegistry.register({ + error, + handler: handler as ExceptionHandler, + }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.exceptionHandlerRegistry.register({ + error, + handler: descriptor?.value, + }); + return descriptor; + }; + } } export { Router }; diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 91d81a4a13..98002419f1 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -60,6 +60,7 @@ type BatchResolverHandler< : | BatchResolverHandlerFn | BatchResolverSyncHandlerFn; +//#endregion // #region Resolver fn @@ -83,6 +84,8 @@ type ResolverHandler> = | ResolverSyncHandlerFn | ResolverHandlerFn; +//#endregion + // #region Resolver registry /** @@ -134,6 +137,8 @@ type RouteHandlerOptions< throwOnError?: R; }; +//#endregion + // #region Router /** @@ -178,6 +183,51 @@ type GraphQlBatchRouteOptions< ? { aggregate?: T; throwOnError?: never } : { aggregate?: T; throwOnError?: R }); +//#endregion + +// #region Exception handling + +type ExceptionSyncHandlerFn = (error: T) => unknown; + +type ExceptionHandlerFn = (error: T) => Promise; + +type ExceptionHandler = + | ExceptionSyncHandlerFn + | ExceptionHandlerFn; + +// biome-ignore lint/suspicious/noExplicitAny: this is a generic type that is intentionally open +type ErrorClass = new (...args: any[]) => T; + +/** + * Options for handling exceptions in the event handler. + * + * @template T - The type of error that extends the base Error class + */ +type ExceptionHandlerOptions = { + /** + * The error class to handle (must be Error or a subclass) + */ + error: ErrorClass | ErrorClass[]; + /** + * The handler function to be called when the error is caught + */ + handler: ExceptionHandler; +}; + +/** + * Options for the {@link ExceptionHandlerRegistry | `ExceptionHandlerRegistry`} class + */ +type ExceptionHandlerRegistryOptions = { + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + logger: Pick; +}; + +//#endregion + export type { RouteHandlerRegistryOptions, RouteHandlerOptions, @@ -188,4 +238,8 @@ export type { BatchResolverHandler, BatchResolverHandlerFn, BatchResolverAggregateHandlerFn, + ExceptionHandler, + ErrorClass, + ExceptionHandlerOptions, + ExceptionHandlerRegistryOptions, }; 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 95cbcc25f8..5a820845a0 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -1,4 +1,5 @@ import context from '@aws-lambda-powertools/testing-utils/context'; +import { AssertionError } from 'assert'; import type { AppSyncResolverEvent, Context } from 'aws-lambda'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; @@ -6,8 +7,28 @@ import { InvalidBatchResponseException, ResolverNotFoundException, } from '../../../src/appsync-graphql/index.js'; +import type { ErrorClass } from '../../../src/types/appsync-graphql.js'; import { onGraphqlEventFactory } from '../../helpers/factories.js'; +class ValidationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ValidationError'; + } +} +class NotFoundError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'NotFoundError'; + } +} +class DatabaseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'DatabaseError'; + } +} + describe('Class: AppSyncGraphQLResolver', () => { beforeEach(() => { vi.clearAllMocks(); @@ -706,4 +727,615 @@ describe('Class: AppSyncGraphQLResolver', () => { expect(resultMutation).toEqual(['scoped', 'scoped']); } ); + + // #region Exception Handling + + it.each([ + { + errorClass: EvalError, + message: 'Evaluation failed', + }, + { + errorClass: RangeError, + message: 'Range failed', + }, + { + errorClass: ReferenceError, + message: 'Reference failed', + }, + { + errorClass: SyntaxError, + message: 'Syntax missing', + }, + { + errorClass: TypeError, + message: 'Type failed', + }, + { + errorClass: URIError, + message: 'URI failed', + }, + { + errorClass: AggregateError, + message: 'Aggregation failed', + }, + ])( + 'should invoke exception handler for %s', + async ({ + errorClass, + message, + }: { + errorClass: ErrorClass; + message: string; + }) => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler(errorClass, async (err) => { + return { + message, + errorName: err.constructor.name, + }; + }); + + app.onQuery('getUser', async () => { + throw errorClass === AggregateError + ? new errorClass([new Error()], message) + : new errorClass(message); + }); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ + message, + errorName: errorClass.name, + }); + } + ); + + it('should handle multiple different error types with specific exception handlers', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler(ValidationError, async (error) => { + return { + message: 'Validation failed', + details: error.message, + type: 'validation_error', + }; + }); + + app.exceptionHandler(NotFoundError, async (error) => { + return { + message: 'Resource not found', + details: error.message, + type: 'not_found_error', + }; + }); + + app.onQuery<{ id: string }>('getUser', async ({ id }) => { + if (!id) { + throw new ValidationError('User ID is required'); + } + if (id === '0') { + throw new NotFoundError(`User with ID ${id} not found`); + } + return { id, name: 'John Doe' }; + }); + + // Act + const validationResult = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + const notFoundResult = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '0' }), + context + ); + + // Asses + expect(validationResult).toEqual({ + message: 'Validation failed', + details: 'User ID is required', + type: 'validation_error', + }); + expect(notFoundResult).toEqual({ + message: 'Resource not found', + details: 'User with ID 0 not found', + type: 'not_found_error', + }); + }); + + it('should prefer exact error class match over inheritance match during exception handling', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler(Error, async (error) => { + return { + message: 'Generic error occurred', + details: error.message, + type: 'generic_error', + }; + }); + + app.exceptionHandler(ValidationError, async (error) => { + return { + message: 'Validation failed', + details: error.message, + type: 'validation_error', + }; + }); + + app.onQuery('getUser', async () => { + throw new ValidationError('Specific validation error'); + }); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ + message: 'Validation failed', + details: 'Specific validation error', + type: 'validation_error', + }); + }); + + it('should fall back to default error formatting when no exception handler is found', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler(AssertionError, async (error) => { + return { + message: 'Validation failed', + details: error.message, + type: 'validation_error', + }; + }); + + app.onQuery('getUser', async () => { + throw new DatabaseError('Database connection failed'); + }); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ + error: 'DatabaseError - Database connection failed', + }); + }); + + it('should fall back to default error formatting when exception handler throws an error', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + const errorToBeThrown = new Error('Exception handler failed'); + + app.exceptionHandler(ValidationError, async () => { + throw errorToBeThrown; + }); + + app.onQuery('getUser', async () => { + throw new ValidationError('Original error'); + }); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ + error: 'ValidationError - Original error', + }); + expect(console.error).toHaveBeenNthCalledWith( + 1, + 'An error occurred in handler getUser', + new ValidationError('Original error') + ); + expect(console.error).toHaveBeenNthCalledWith( + 2, + 'Exception handler for ValidationError threw an error', + errorToBeThrown + ); + }); + + it('should invoke sync exception handlers and return their result', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler(ValidationError, (error) => { + return { + message: 'This is a sync handler', + details: error.message, + type: 'sync_validation_error', + }; + }); + + app.onQuery('getUser', async () => { + throw new ValidationError('Sync error test'); + }); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ + message: 'This is a sync handler', + details: 'Sync error test', + type: 'sync_validation_error', + }); + }); + + it('should not interfere with ResolverNotFoundException during exception handling', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler(RangeError, async (error) => { + return { + message: 'This should not be called', + details: error.message, + type: 'should_not_happen', + }; + }); + + // Act & Assess + await expect( + app.resolve( + onGraphqlEventFactory('nonExistentResolver', 'Query'), + context + ) + ).rejects.toThrow('No resolver found for Query-nonExistentResolver'); + }); + + it('should work as a method decorator for `exceptionHandler`', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + class Lambda { + public readonly scope = 'scoped'; + + @app.exceptionHandler(ValidationError) + async handleValidationError(error: ValidationError) { + return { + message: 'Decorator validation failed', + details: error.message, + type: 'decorator_validation_error', + scope: this.scope, + }; + } + + @app.exceptionHandler(NotFoundError) + handleNotFoundError(error: NotFoundError) { + return { + message: 'Decorator user not found', + details: error.message, + type: 'decorator_user_not_found', + scope: this.scope, + }; + } + + @app.onQuery('getUser') + async getUser({ id, name }: { id: string; name: string }) { + if (!id) { + throw new ValidationError('Decorator error test'); + } + if (id === '0') { + throw new NotFoundError(`User with ID ${id} not found`); + } + return { id, name }; + } + + 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 validationError = await handler( + onGraphqlEventFactory('getUser', 'Query', {}), + context + ); + const notFoundError = await handler( + onGraphqlEventFactory('getUser', 'Query', { id: '0', name: 'John Doe' }), + context + ); + + // Assess + expect(validationError).toEqual({ + message: 'Decorator validation failed', + details: 'Decorator error test', + type: 'decorator_validation_error', + scope: 'scoped', + }); + expect(notFoundError).toEqual({ + message: 'Decorator user not found', + details: 'User with ID 0 not found', + type: 'decorator_user_not_found', + scope: 'scoped', + }); + }); + + it('should handle array of error classes with single exception handler function', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + app.exceptionHandler([ValidationError, NotFoundError], async (error) => { + return { + message: 'User service error', + details: error.message, + type: 'user_service_error', + errorClass: error.name, + }; + }); + + app.onQuery<{ id: string }>('getId', async ({ id }) => { + if (!id) { + throw new ValidationError('User ID is required for retrieval'); + } + if (id === 'missing') { + throw new NotFoundError('Requested user does not exist'); + } + if (id === 'database-error') { + throw new DatabaseError('Database connection timeout'); + } + return { id, name: 'Retrieved User' }; + }); + + // Act + const validationResult = await app.resolve( + onGraphqlEventFactory('getId', 'Query', {}), + context + ); + const notFoundResult = await app.resolve( + onGraphqlEventFactory('getId', 'Query', { id: 'missing' }), + context + ); + const databaseErrorResult = await app.resolve( + onGraphqlEventFactory('getId', 'Query', { id: 'database-error' }), + context + ); + + // Assess + expect(console.debug).toHaveBeenCalledWith( + 'Adding exception handler for error class ValidationError' + ); + expect(console.debug).toHaveBeenCalledWith( + 'Adding exception handler for error class NotFoundError' + ); + expect(validationResult).toEqual({ + message: 'User service error', + details: 'User ID is required for retrieval', + type: 'user_service_error', + errorClass: 'ValidationError', + }); + expect(notFoundResult).toEqual({ + message: 'User service error', + details: 'Requested user does not exist', + type: 'user_service_error', + errorClass: 'NotFoundError', + }); + expect(databaseErrorResult).toEqual({ + error: 'DatabaseError - Database connection timeout', + }); + }); + + it('should preserve scope when using array error handler as method decorator', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + class OrderServiceLambda { + public readonly serviceName = 'OrderService'; + + @app.exceptionHandler([ValidationError, NotFoundError]) + async handleOrderErrors(error: ValidationError | NotFoundError) { + return { + message: `${this.serviceName} encountered an error`, + details: error.message, + type: 'order_service_error', + errorClass: error.name, + service: this.serviceName, + }; + } + + @app.onQuery('getOrder') + async getOrderById({ orderId }: { orderId: string }) { + if (!orderId) { + throw new ValidationError('Order ID is required'); + } + if (orderId === 'order-404') { + throw new NotFoundError('Order not found in system'); + } + if (orderId === 'db-error') { + throw new DatabaseError('Database unavailable'); + } + return { orderId, status: 'found', service: this.serviceName }; + } + + async handler(event: unknown, context: Context) { + const resolved = app.resolve(event, context, { + scope: this, + }); + return resolved; + } + } + + const orderServiceLambda = new OrderServiceLambda(); + const orderHandler = orderServiceLambda.handler.bind(orderServiceLambda); + + // Act + const validationResult = await orderHandler( + onGraphqlEventFactory('getOrder', 'Query', {}), + context + ); + const notFoundResult = await orderHandler( + onGraphqlEventFactory('getOrder', 'Query', { orderId: 'order-404' }), + context + ); + const databaseErrorResult = await orderHandler( + onGraphqlEventFactory('getOrder', 'Query', { orderId: 'db-error' }), + context + ); + const successResult = await orderHandler( + onGraphqlEventFactory('getOrder', 'Query', { orderId: 'order-123' }), + context + ); + + // Assess + expect(validationResult).toEqual({ + message: 'OrderService encountered an error', + details: 'Order ID is required', + type: 'order_service_error', + errorClass: 'ValidationError', + service: 'OrderService', + }); + expect(notFoundResult).toEqual({ + message: 'OrderService encountered an error', + details: 'Order not found in system', + type: 'order_service_error', + errorClass: 'NotFoundError', + service: 'OrderService', + }); + expect(successResult).toEqual({ + orderId: 'order-123', + status: 'found', + service: 'OrderService', + }); + expect(databaseErrorResult).toEqual({ + error: 'DatabaseError - Database unavailable', + }); + }); + + it('should handle mix of single and array error handlers with proper precedence', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.exceptionHandler([ValidationError, TypeError], async (error) => { + return { + message: 'Payment validation error', + details: error.message, + type: 'payment_validation_error', + errorClass: error.name, + }; + }); + + app.exceptionHandler(ValidationError, async (error) => { + return { + message: 'Specific payment validation error', + details: error.message, + type: 'specific_payment_validation_error', + errorClass: error.name, + }; + }); + + app.onQuery<{ amount: number; currency: string }>( + 'getPayment', + async ({ amount, currency }) => { + if (!amount || amount <= 0) { + throw new ValidationError('Invalid payment amount'); + } + if (!currency) { + throw new TypeError('Currency type is required'); + } + if (currency === 'INVALID') { + throw new RangeError('Unsupported currency'); + } + return { amount, currency, status: 'validated' }; + } + ); + + // Act + const validationResult = await app.resolve( + onGraphqlEventFactory('getPayment', 'Query', { + amount: 0, + currency: 'USD', + }), + context + ); + const typeErrorResult = await app.resolve( + onGraphqlEventFactory('getPayment', 'Query', { amount: 100 }), + context + ); + const rangeErrorResult = await app.resolve( + onGraphqlEventFactory('getPayment', 'Query', { + amount: 100, + currency: 'INVALID', + }), + context + ); + + // Assess + expect(validationResult).toEqual({ + message: 'Specific payment validation error', + details: 'Invalid payment amount', + type: 'specific_payment_validation_error', + errorClass: 'ValidationError', + }); + expect(typeErrorResult).toEqual({ + message: 'Payment validation error', + details: 'Currency type is required', + type: 'payment_validation_error', + errorClass: 'TypeError', + }); + expect(rangeErrorResult).toEqual({ + error: 'RangeError - Unsupported currency', + }); + }); + + it('should handle empty array of error classes gracefully', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + app.exceptionHandler([], async (error) => { + return { + message: 'This should never be called', + details: error.message, + }; + }); + + app.onQuery<{ requestId: string }>('getId', async ({ requestId }) => { + if (requestId === 'validation-error') { + throw new ValidationError('Invalid request format'); + } + return { requestId, status: 'processed' }; + }); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getId', 'Query', { + requestId: 'validation-error', + }), + context + ); + + // Assess + expect(result).toEqual({ + error: 'ValidationError - Invalid request format', + }); + expect(console.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Adding exception handler for error class') + ); + }); + + // #endregion Exception handling }); diff --git a/packages/event-handler/tests/unit/appsync-graphql/ExceptionHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/ExceptionHandlerRegistry.test.ts new file mode 100644 index 0000000000..9912254179 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/ExceptionHandlerRegistry.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExceptionHandlerRegistry } from '../../../src/appsync-graphql/ExceptionHandlerRegistry.js'; +import type { ExceptionHandlerOptions } from '../../../src/types/appsync-graphql.js'; + +describe('Class: ExceptionHandlerRegistry', () => { + class MockExceptionHandlerRegistry extends ExceptionHandlerRegistry { + public declare handlers: Map; + } + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + + const getRegistry = () => + new MockExceptionHandlerRegistry({ logger: console }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers an exception handler for an error class', () => { + // Prepare + const handler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: CustomError, handler }); + + // Assess + expect(registry.handlers.size).toBe(1); + expect(registry.handlers.get('CustomError')).toBeDefined(); + }); + + it('logs a warning and replaces the previous handler if the error class is already registered', () => { + // Prepare + const originalHandler = vi.fn(); + const otherHandler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: CustomError, handler: originalHandler }); + registry.register({ error: CustomError, handler: otherHandler }); + + // Assess + expect(registry.handlers.size).toBe(1); + expect(registry.handlers.get('CustomError')).toEqual({ + error: CustomError, + handler: otherHandler, + }); + expect(console.warn).toHaveBeenCalledWith( + "An exception handler for error class 'CustomError' is already registered. The previous handler will be replaced." + ); + }); + + it('resolve returns the correct handler for a registered error instance', () => { + // Prepare + const customErrorHandler = vi.fn(); + const rangeErrorHandler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: CustomError, handler: customErrorHandler }); + registry.register({ error: RangeError, handler: rangeErrorHandler }); + const resolved = registry.resolve(new CustomError('fail')); + + // Assess + expect(resolved).toBe(customErrorHandler); + }); + + it('resolve returns undefined if no handler is registered for the error', () => { + // Prepare + class OtherError extends Error {} + const handler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: CustomError, handler }); + const resolved = registry.resolve(new OtherError('fail')); + + // Assess + expect(resolved).toBeNull(); + }); + + it('registers an exception handler for multiple error classes using an array', () => { + // Prepare + class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } + } + class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } + } + const handler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ + error: [ValidationError, AuthenticationError], + handler, + }); + + // Assess + expect(registry.handlers.size).toBe(2); + expect(registry.handlers.get('ValidationError')).toEqual({ + error: ValidationError, + handler, + }); + expect(registry.handlers.get('AuthenticationError')).toEqual({ + error: AuthenticationError, + handler, + }); + }); + + it('registers different handlers for different error arrays', () => { + // Prepare + class DatabaseError extends Error { + constructor(message: string) { + super(message); + this.name = 'DatabaseError'; + } + } + class ConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConnectionError'; + } + } + class UIError extends Error { + constructor(message: string) { + super(message); + this.name = 'UIError'; + } + } + const backendHandler = vi.fn(); + const frontendHandler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ + error: [DatabaseError, ConnectionError], + handler: backendHandler, + }); + registry.register({ + error: [UIError], + handler: frontendHandler, + }); + + // Assess + expect(registry.handlers.size).toBe(3); + expect(registry.resolve(new DatabaseError('DB failed'))).toBe( + backendHandler + ); + expect(registry.resolve(new ConnectionError('Connection failed'))).toBe( + backendHandler + ); + expect(registry.resolve(new UIError('UI failed'))).toBe(frontendHandler); + }); + + it('logs warnings and replaces handlers when error classes in array are already registered', () => { + // Prepare + class ConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } + } + class DuplicateError extends Error { + constructor(message: string) { + super(message); + this.name = 'DuplicateError'; + } + } + const originalHandler = vi.fn(); + const newHandler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: ConflictError, handler: originalHandler }); + registry.register({ + error: [ConflictError, DuplicateError], + handler: newHandler, + }); + + // Assess + expect(registry.handlers.size).toBe(2); + expect(registry.handlers.get('ConflictError')).toEqual({ + error: ConflictError, + handler: newHandler, + }); + expect(registry.handlers.get('DuplicateError')).toEqual({ + error: DuplicateError, + handler: newHandler, + }); + expect(console.warn).toHaveBeenCalledWith( + "An exception handler for error class 'ConflictError' is already registered. The previous handler will be replaced." + ); + }); + + it('handles mixed registration of single errors and error arrays', () => { + // Prepare + class SingleError extends Error { + constructor(message: string) { + super(message); + this.name = 'SingleError'; + } + } + class ArrayError1 extends Error { + constructor(message: string) { + super(message); + this.name = 'ArrayError1'; + } + } + class ArrayError2 extends Error { + constructor(message: string) { + super(message); + this.name = 'ArrayError2'; + } + } + const singleHandler = vi.fn(); + const arrayHandler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: SingleError, handler: singleHandler }); + registry.register({ + error: [ArrayError1, ArrayError2], + handler: arrayHandler, + }); + + // Assess + expect(registry.handlers.size).toBe(3); + expect(registry.resolve(new SingleError('Single error'))).toBe( + singleHandler + ); + expect(registry.resolve(new ArrayError1('Array error 1'))).toBe( + arrayHandler + ); + expect(registry.resolve(new ArrayError2('Array error 2'))).toBe( + arrayHandler + ); + }); + + it('handles empty array of errors gracefully', () => { + // Prepare + const handler = vi.fn(); + const registry = getRegistry(); + + // Act + registry.register({ error: [], handler }); + + // Assess + expect(registry.handlers.size).toBe(0); + }); +}); 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 6dcd7dca26..5c5b20301f 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -1,5 +1,5 @@ -import { Router } from 'src/appsync-graphql/Router.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Router } from '../../../src/appsync-graphql/Router.js'; describe('Class: Router', () => { beforeEach(() => {