diff --git a/docs/features/event-handler/appsync-graphql.md b/docs/features/event-handler/appsync-graphql.md index 9112da2c01..ba4565ed5d 100644 --- a/docs/features/event-handler/appsync-graphql.md +++ b/docs/features/event-handler/appsync-graphql.md @@ -114,6 +114,64 @@ Here's a table with their related scalar as a quick reference: ## Advanced +### Split operations with Router + +As you grow the number of related GraphQL operations a given Lambda function should handle, it is natural to split them into separate files to ease maintenance - That's when the `Router` feature comes handy. + +Let's assume you have `app.ts` as your Lambda function entrypoint and routes in `postRouter.ts` and `userRouter.ts`. This is how you'd use the `Router` feature. + +=== "postRouter.ts" + + We import **Router** instead of **AppSyncGraphQLResolver**; syntax wise is exactly the same. + + ```typescript hl_lines="1 3" + --8<-- "examples/snippets/event-handler/appsync-graphql/postRouter.ts" + ``` + +=== "userRouter.ts" + + We import **Router** instead of **AppSyncGraphQLResolver**; syntax wise is exactly the same. + + ```typescript hl_lines="1 3" + --8<-- "examples/snippets/event-handler/appsync-graphql/userRouter.ts" + ``` + +=== "app.ts" + + We use `includeRouter` method and include all operations registered in the router instances. + + ```typescript hl_lines="3-4 8" + --8<-- "examples/snippets/event-handler/appsync-graphql/splitRouter.ts" + ``` + +#### Sharing contextual data + +You can use `appendContext` when you want to share data between your App and Router instances. Any data you share will be available via the `sharedContext` parameter in your resolver handlers. + +???+ warning + For safety, we clear the context after each invocation. + +???+ tip + This can also be useful for injecting contextual information before a request is processed. + +=== "app.ts" + + ```typescript hl_lines="10" + --8<-- "examples/snippets/event-handler/appsync-graphql/appendContext.ts" + ``` + +=== "postRouter.ts" + + ```typescript hl_lines="5-8" + --8<-- "examples/snippets/event-handler/appsync-graphql/postRouterWithContext.ts" + ``` + +=== "userRouter.ts" + + ```typescript hl_lines="5-8" + --8<-- "examples/snippets/event-handler/appsync-graphql/userRouterWithContext.ts" + ``` + ### Nested mappings !!! note diff --git a/examples/snippets/event-handler/appsync-graphql/appendContext.ts b/examples/snippets/event-handler/appsync-graphql/appendContext.ts new file mode 100644 index 0000000000..7301ae58f1 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/appendContext.ts @@ -0,0 +1,13 @@ +import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; +import type { Context } from 'aws-lambda/handler'; +import { postRouter } from './postRouter'; +import { userRouter } from './userRouter'; + +const app = new AppSyncGraphQLResolver(); + +app.includeRouter([postRouter, userRouter]); + +app.appendContext({ requestId: crypto.randomUUID() }); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-graphql/postRouter.ts b/examples/snippets/event-handler/appsync-graphql/postRouter.ts new file mode 100644 index 0000000000..34bebb4e4c --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/postRouter.ts @@ -0,0 +1,18 @@ +import { Router } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + +const postRouter = new Router(); + +postRouter.onQuery('getPosts', async () => { + return [{ id: 1, title: 'First post', content: 'Hello world!' }]; +}); + +postRouter.onMutation('createPost', async ({ title, content }) => { + return { + id: Date.now(), + title, + content, + createdAt: new Date().toISOString(), + }; +}); + +export { postRouter }; diff --git a/examples/snippets/event-handler/appsync-graphql/postRouterWithContext.ts b/examples/snippets/event-handler/appsync-graphql/postRouterWithContext.ts new file mode 100644 index 0000000000..7c33932e38 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/postRouterWithContext.ts @@ -0,0 +1,10 @@ +import { Router } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + +const postRouter = new Router(); + +postRouter.onQuery('getPosts', async (args, { sharedContext }) => { + const requestId = sharedContext?.get('requestId'); + return [{ id: 1, title: 'First post', content: 'Hello world!', requestId }]; +}); + +export { postRouter }; diff --git a/examples/snippets/event-handler/appsync-graphql/splitRouter.ts b/examples/snippets/event-handler/appsync-graphql/splitRouter.ts new file mode 100644 index 0000000000..2f4948a9d3 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/splitRouter.ts @@ -0,0 +1,11 @@ +import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; +import type { Context } from 'aws-lambda'; +import { postRouter } from './postRouter'; +import { userRouter } from './userRouter'; + +const app = new AppSyncGraphQLResolver(); + +app.includeRouter([postRouter, userRouter]); + +export const handler = async (event: unknown, context: Context) => + app.resolve(event, context); diff --git a/examples/snippets/event-handler/appsync-graphql/userRouter.ts b/examples/snippets/event-handler/appsync-graphql/userRouter.ts new file mode 100644 index 0000000000..51e35b82d4 --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/userRouter.ts @@ -0,0 +1,9 @@ +import { Router } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + +const userRouter = new Router(); + +userRouter.onQuery('getUsers', async () => { + return [{ id: 1, name: 'John Doe', email: 'john@example.com' }]; +}); + +export { userRouter }; diff --git a/examples/snippets/event-handler/appsync-graphql/userRouterWithContext.ts b/examples/snippets/event-handler/appsync-graphql/userRouterWithContext.ts new file mode 100644 index 0000000000..9c09ea6d3a --- /dev/null +++ b/examples/snippets/event-handler/appsync-graphql/userRouterWithContext.ts @@ -0,0 +1,10 @@ +import { Router } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + +const userRouter = new Router(); + +userRouter.onQuery('getUsers', async (args, { sharedContext }) => { + const requestId = sharedContext?.get('requestId'); + return [{ id: 1, name: 'John Doe', email: 'john@example.com', requestId }]; +}); + +export { userRouter }; diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 6aede4c554..2c773fb045 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -2,6 +2,7 @@ import type { AppSyncResolverEvent, Context } from 'aws-lambda'; import type { BatchResolverAggregateHandlerFn, BatchResolverHandlerFn, + GraphQlRouterOptions, ResolverHandler, RouteHandlerOptions, } from '../types/appsync-graphql.js'; @@ -42,6 +43,16 @@ import { isAppSyncGraphQLEvent } from './utils.js'; * ``` */ class AppSyncGraphQLResolver extends Router { + /** + * A map to hold shared contextual data accessible to all resolver handlers. + */ + public readonly sharedContext: Map; + + public constructor(options?: GraphQlRouterOptions) { + super(options); + this.sharedContext = new Map(); + } + /** * Resolve the response based on the provided event and route handlers configured. * @@ -160,11 +171,19 @@ class AppSyncGraphQLResolver extends Router { ); return; } - return this.#withErrorHandling( - () => this.#executeBatchResolvers(event, context, options), - event[0], - options - ); + + try { + return this.#withErrorHandling( + () => this.#executeBatchResolvers(event, context, options), + event[0], + options + ); + } finally { + /** + * Clear shared context after batch processing for safety + */ + this.sharedContext.clear(); + } } if (!isAppSyncGraphQLEvent(event)) { this.logger.warn( @@ -173,11 +192,96 @@ class AppSyncGraphQLResolver extends Router { return; } - return this.#withErrorHandling( - () => this.#executeSingleResolver(event, context, options), - event, - options - ); + try { + return this.#withErrorHandling( + () => this.#executeSingleResolver(event, context, options), + event, + options + ); + } finally { + /** + * Clear shared context after batch processing for safety + */ + this.sharedContext.clear(); + } + } + + /** + * Includes one or more routers and merges their registries into the current resolver. + * + * This method allows you to compose multiple routers by merging their + * route registries into the current AppSync GraphQL resolver instance. + * All resolver handlers, batch resolver handlers, and exception handlers + * from the included routers will be available in the current resolver. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver, Router } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const postRouter = new Router(); + * postRouter.onQuery('getPosts', async () => [{ id: 1, title: 'Post 1' }]); + * + * const userRouter = new Router(); + * userRouter.onQuery('getUsers', async () => [{ id: 1, name: 'John Doe' }]); + * + * const app = new AppSyncGraphQLResolver(); + * + * app.includeRouter([userRouter, postRouter]); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * @param router - The router instance or array of router instances whose registries will be merged + */ + public includeRouter(router: Router | Router[]): void { + const routers = Array.isArray(router) ? router : [router]; + + this.logger.debug('Including router'); + for (const routerToBeIncluded of routers) { + this.mergeRegistriesFrom(routerToBeIncluded); + } + this.logger.debug('Router included successfully'); + } + + /** + * Appends contextual data to be shared with all resolver handlers. + * + * This method allows you to add key-value pairs to the shared context that will be + * accessible to all resolver handlers through the `sharedContext` parameter. The context + * is automatically cleared after each invocation for safety. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver, Router } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const postRouter = new Router(); + * postRouter.onQuery('getPosts', async ({ sharedContext }) => { + * const requestId = sharedContext?.get('requestId'); + * return [{ id: 1, title: 'Post 1', requestId }]; + * }); + * + * const userRouter = new Router(); + * userRouter.onQuery('getUsers', async ({ sharedContext }) => { + * const requestId = sharedContext?.get('requestId'); + * return [{ id: 1, name: 'John Doe', requestId }]; + * }); + * + * const app = new AppSyncGraphQLResolver(); + * + * app.includeRouter([userRouter, postRouter]); + * app.appendContext({ requestId: '12345' }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * @param data - A record of key-value pairs to add to the shared context + */ + public appendContext(data: Record): void { + for (const [key, value] of Object.entries(data)) { + this.sharedContext.set(key, value); + } } /** @@ -315,7 +419,11 @@ class AppSyncGraphQLResolver extends Router { options.handler as BatchResolverAggregateHandlerFn ).apply(resolveOptions?.scope ?? this, [ events, - { event: events, context }, + { + event: events, + context, + ...this.#getSharedContextOnlyIfNotEmpty(), + }, ]); if (!Array.isArray(response)) { @@ -334,7 +442,11 @@ class AppSyncGraphQLResolver extends Router { for (const event of events) { const result = await handler.apply(resolveOptions?.scope ?? this, [ event.arguments, - { event, context }, + { + event, + context, + ...this.#getSharedContextOnlyIfNotEmpty(), + }, ]); results.push(result); } @@ -345,7 +457,11 @@ class AppSyncGraphQLResolver extends Router { try { const result = await handler.apply(resolveOptions?.scope ?? this, [ events[i].arguments, - { event: events[i], context }, + { + event: events[i], + context, + ...this.#getSharedContextOnlyIfNotEmpty(), + }, ]); results.push(result); } catch (error) { @@ -387,7 +503,14 @@ class AppSyncGraphQLResolver extends Router { if (resolverHandlerOptions) { return (resolverHandlerOptions.handler as ResolverHandler).apply( options?.scope ?? this, - [event.arguments, { event, context }] + [ + event.arguments, + { + event, + context, + ...this.#getSharedContextOnlyIfNotEmpty(), + }, + ] ); } @@ -411,6 +534,19 @@ class AppSyncGraphQLResolver extends Router { error: 'An unknown error occurred', }; } + + /** + * Returns an object containing the shared context only if it has entries. + * This helps avoid passing an empty map to handlers. + */ + #getSharedContextOnlyIfNotEmpty(): { + sharedContext: Map | undefined; + } { + return { + sharedContext: + this.sharedContext.size > 0 ? this.sharedContext : undefined, + }; + } } export { AppSyncGraphQLResolver }; diff --git a/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts index 261c426b8d..e247f910ca 100644 --- a/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts @@ -37,7 +37,44 @@ class ExceptionHandlerRegistry { const errors = Array.isArray(error) ? error : [error]; for (const err of errors) { - this.registerErrorHandler(err, handler); + this.#registerErrorHandler(err, 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; + } + + /** + * Merges handlers from another ExceptionHandlerRegistry into this registry. + * Existing handlers for the same error class will be replaced and a warning will be logged. + * + * @param otherRegistry - The registry to merge handlers from. + */ + public merge(otherRegistry: ExceptionHandlerRegistry): void { + for (const [errorName, handlerOptions] of otherRegistry.handlers) { + if (this.handlers.has(errorName)) { + this.#warnHandlerOverriding(errorName); + } + this.handlers.set(errorName, handlerOptions); } } @@ -47,7 +84,7 @@ class ExceptionHandlerRegistry { * @param errorClass - The error class to register the handler for. * @param handler - The exception handler function. */ - private registerErrorHandler( + #registerErrorHandler( errorClass: ErrorClass, handler: ExceptionHandler ): void { @@ -56,9 +93,7 @@ class ExceptionHandlerRegistry { 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.#warnHandlerOverriding(errorName); } this.handlers.set(errorName, { @@ -68,25 +103,18 @@ class ExceptionHandlerRegistry { } /** - * Resolves and returns the appropriate exception handler for a given error instance. + * Logs a warning message when an exception handler is being overridden. * - * 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. + * This method is called internally when registering a new exception handler + * for an error class that already has a handler registered. It warns the user + * that the previous handler will be replaced with the new one. * - * @param error - The error instance for which to resolve an exception handler. + * @param errorName - The name of the error class for which a handler is being overridden */ - 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; + #warnHandlerOverriding(errorName: string): void { + this.#logger.warn( + `An exception handler for error class '${errorName}' is already registered. The previous handler will be replaced.` + ); } } diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index ac6c2ff2aa..14614dbcef 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -50,9 +50,7 @@ class RouteHandlerRegistry { this.#logger.debug(`Adding resolver for field ${typeName}.${fieldName}`); const cacheKey = this.#makeKey(typeName, fieldName); if (this.resolvers.has(cacheKey)) { - this.#logger.warn( - `A resolver for field '${fieldName}' is already registered for '${typeName}'. The previous resolver will be replaced.` - ); + this.#warnResolverOverriding(fieldName, typeName); } this.resolvers.set(cacheKey, { fieldName, @@ -81,6 +79,21 @@ class RouteHandlerRegistry { return this.resolvers.get(this.#makeKey(typeName, fieldName)); } + /** + * Merges handlers from another RouteHandlerRegistry into this registry. + * Existing handlers with the same key will be replaced and a warning will be logged. + * + * @param otherRegistry - The registry to merge handlers from. + */ + public merge(otherRegistry: RouteHandlerRegistry): void { + for (const [key, handler] of otherRegistry.resolvers) { + if (this.resolvers.has(key)) { + this.#warnResolverOverriding(handler.fieldName, handler.typeName); + } + this.resolvers.set(key, handler); + } + } + /** * Generates a unique key by combining the provided GraphQL type name and field name. * @@ -90,6 +103,19 @@ class RouteHandlerRegistry { #makeKey(typeName: string, fieldName: string): string { return `${typeName}.${fieldName}`; } + + /** + * Logs a warning message indicating that a resolver for the specified field and type + * is already registered and will be replaced by a new resolver. + * + * @param fieldName - The name of the field for which the resolver is being overridden. + * @param typeName - The name of the type associated with the field. + */ + #warnResolverOverriding(fieldName: string, typeName: string): void { + this.#logger.warn( + `A resolver for field '${fieldName}' is already registered for '${typeName}'. The previous resolver will be replaced.` + ); + } } export { RouteHandlerRegistry }; diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 29745e2663..b84a268fba 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -64,6 +64,20 @@ class Router { this.isDev = isDevMode(); } + /** + * Merges resolver registries from another router into this router. + * + * This method combines the resolver registry, batch resolver registry, and exception handler registry + * from the provided router with the current router's registries. + * + * @param router - The source router whose registries will be merged into this router + */ + protected mergeRegistriesFrom(router: Router): void { + this.resolverRegistry.merge(router.resolverRegistry); + this.batchResolverRegistry.merge(router.batchResolverRegistry); + this.exceptionHandlerRegistry.merge(router.exceptionHandlerRegistry); + } + /** * Register a resolver function for any GraphQL event. * diff --git a/packages/event-handler/src/appsync-graphql/index.ts b/packages/event-handler/src/appsync-graphql/index.ts index bffc35bad1..9e38af73d6 100644 --- a/packages/event-handler/src/appsync-graphql/index.ts +++ b/packages/event-handler/src/appsync-graphql/index.ts @@ -3,6 +3,7 @@ export { InvalidBatchResponseException, ResolverNotFoundException, } from './errors.js'; +export { Router } from './Router.js'; export { awsDate, awsDateTime, diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index cd4f8685ed..aeb893ab29 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -14,6 +14,7 @@ type BatchResolverSyncHandlerFn< options: { event: AppSyncResolverEvent; context: Context; + sharedContext?: Map; } ) => unknown; @@ -25,6 +26,7 @@ type BatchResolverHandlerFn< options: { event: AppSyncResolverEvent; context: Context; + sharedContext?: Map; } ) => Promise; @@ -36,6 +38,7 @@ type BatchResolverAggregateHandlerFn< options: { event: AppSyncResolverEvent[]; context: Context; + sharedContext?: Map; } ) => Promise; @@ -47,6 +50,7 @@ type BatchResolverSyncAggregateHandlerFn< options: { event: AppSyncResolverEvent[]; context: Context; + sharedContext?: Map; } ) => unknown; @@ -70,6 +74,7 @@ type ResolverSyncHandlerFn> = ( options: { event: AppSyncResolverEvent; context: Context; + sharedContext?: Map; } ) => unknown; @@ -78,6 +83,7 @@ type ResolverHandlerFn> = ( options: { event: AppSyncResolverEvent; context: Context; + sharedContext?: Map; } ) => Promise; 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 1100a41005..8f3c280cc2 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -5,7 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; import { InvalidBatchResponseException, + makeId, ResolverNotFoundException, + Router, } from '../../../src/appsync-graphql/index.js'; import type { ErrorClass } from '../../../src/types/appsync-graphql.js'; import { onGraphqlEventFactory } from '../../helpers/factories.js'; @@ -1327,4 +1329,703 @@ describe('Class: AppSyncGraphQLResolver', () => { }); // #endregion Exception handling + + // #region includeRouter + + it('handles multiple routers and resolves their handlers correctly', async () => { + // Prepare + const userRouter = new Router(); + userRouter.onQuery<{ id: string }>('getUser', async ({ id }) => ({ + id, + name: 'John Doe', + })); + + userRouter.onMutation<{ name: string; email: string }>( + 'createUser', + async ({ name, email }) => ({ + id: makeId(), + name, + email, + }) + ); + + const todoRouter = new Router(); + todoRouter.onQuery<{ id: string }>('getTodo', async ({ id }) => ({ + id, + title: 'Sample Todo', + completed: false, + })); + + const app = new AppSyncGraphQLResolver({ logger: console }); + app.includeRouter([userRouter, todoRouter]); + + // Act + const getUserResult = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '123' }), + context + ); + const createUserResult = await app.resolve( + onGraphqlEventFactory('createUser', 'Mutation', { + name: 'Jane Doe', + email: 'jane.doe@example.com', + }), + context + ); + const todoResult = await app.resolve( + onGraphqlEventFactory('getTodo', 'Query', { id: '456' }), + context + ); + + // Assess + expect(getUserResult).toEqual({ id: '123', name: 'John Doe' }); + expect(createUserResult).toEqual({ + id: expect.any(String), + name: 'Jane Doe', + email: 'jane.doe@example.com', + }); + expect(todoResult).toEqual({ + id: '456', + title: 'Sample Todo', + completed: false, + }); + }); + + it('handles multiple routers with batch resolvers and resolves their handlers correctly', async () => { + // Prepare + const postRouter = new Router(); + postRouter.onBatchQuery('getPosts', async (events) => + events.map((event) => ({ + id: event.arguments.id, + title: `Post ${event.arguments.id}`, + })) + ); + + const todoRouter = new Router(); + todoRouter.onBatchQuery('getTodos', async (events) => + events.map((event) => ({ + id: event.arguments.id, + title: `Todo ${event.arguments.id}`, + })) + ); + + const app = new AppSyncGraphQLResolver({ logger: console }); + app.includeRouter(postRouter); + app.includeRouter(todoRouter); + + // Act + const postResults = await app.resolve( + [ + onGraphqlEventFactory('getPosts', 'Query', { id: '1' }), + onGraphqlEventFactory('getPosts', 'Query', { id: '2' }), + ], + context + ); + const todoResults = await app.resolve( + [ + onGraphqlEventFactory('getTodos', 'Query', { id: '1' }), + onGraphqlEventFactory('getTodos', 'Query', { id: '2' }), + ], + context + ); + + // Assess + expect(postResults).toEqual([ + { id: '1', title: 'Post 1' }, + { id: '2', title: 'Post 2' }, + ]); + expect(todoResults).toEqual([ + { id: '1', title: 'Todo 1' }, + { id: '2', title: 'Todo 2' }, + ]); + }); + + it('handles multiple routers with exception handlers', async () => { + // Prepare + const firstRouter = new Router(); + firstRouter.exceptionHandler(ValidationError, async (error) => ({ + error: `Handled: ${error.message}`, + type: 'validation', + })); + firstRouter.resolver( + async () => { + throw new ValidationError('Test validation error'); + }, + { fieldName: 'firstHandler' } + ); + + const secondRouter = new Router(); + secondRouter.exceptionHandler(EvalError, async (error) => ({ + error: `Handled: ${error.message}`, + type: 'evaluation', + })); + secondRouter.resolver( + async () => { + throw new EvalError('Test evaluation error'); + }, + { fieldName: 'secondHandler' } + ); + + const app = new AppSyncGraphQLResolver({ logger: console }); + app.includeRouter(firstRouter); + app.includeRouter(secondRouter); + + // Act + const firstResult = await app.resolve( + onGraphqlEventFactory('firstHandler', 'Query', { shouldThrow: true }), + context + ); + const secondResult = await app.resolve( + onGraphqlEventFactory('secondHandler', 'Query', { shouldThrow: true }), + context + ); + + // Assess + expect(firstResult).toEqual({ + error: 'Handled: Test validation error', + type: 'validation', + }); + expect(secondResult).toEqual({ + error: 'Handled: Test evaluation error', + type: 'evaluation', + }); + }); + + it('handles conflicts when including multiple routers with same resolver', async () => { + // Prepare + const firstRouter = new Router(); + firstRouter.onQuery('getTest', () => ({ + source: 'first', + })); + + const secondRouter = new Router(); + secondRouter.onQuery('getTest', () => ({ + source: 'second', + })); + + const app = new AppSyncGraphQLResolver({ logger: console }); + app.includeRouter(firstRouter); + app.includeRouter(secondRouter); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getTest', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ source: 'second' }); + expect(console.warn).toHaveBeenCalledWith( + "A resolver for field 'getTest' is already registered for 'Query'. The previous resolver will be replaced." + ); + }); + + it('handles conflicts when including multiple routers with same exception handler', async () => { + // Prepare + const firstRouter = new Router(); + firstRouter.exceptionHandler(ValidationError, async (error) => ({ + source: 'first', + message: error.message, + type: 'first_validation', + })); + firstRouter.onQuery('testError', async () => { + throw new ValidationError('Test validation error'); + }); + + const secondRouter = new Router(); + secondRouter.exceptionHandler(ValidationError, async (error) => ({ + source: 'second', + message: error.message, + type: 'second_validation', + })); + secondRouter.onQuery('testError', async () => { + throw new ValidationError('Test validation error'); + }); + + const app = new AppSyncGraphQLResolver({ logger: console }); + app.includeRouter(firstRouter); + app.includeRouter(secondRouter); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('testError', 'Query', {}), + context + ); + + // Assess + expect(result).toEqual({ + source: 'second', + message: 'Test validation error', + type: 'second_validation', + }); + expect(console.warn).toHaveBeenCalledWith( + "An exception handler for error class 'ValidationError' is already registered. The previous handler will be replaced." + ); + }); + + it('works as a method decorator for `includeRouter`', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + const userRouter = new Router(); + const todoRouter = new Router(); + + class Lambda { + public scope = 'scoped'; + + @userRouter.onQuery('getUser') + async getUserById({ id }: { id: string }) { + if (id.length === 0) + throw new ValidationError('User ID cannot be empty'); + return { id, name: 'John Doe', scope: this.scope }; + } + + @userRouter.onMutation('createUser') + async createUser({ name, email }: { name: string; email: string }) { + return { id: makeId(), name, email, scope: this.scope }; + } + + @userRouter.exceptionHandler(ValidationError) + async handleValidationError(error: ValidationError) { + return { + message: 'UserRouter validation error', + details: error.message, + type: 'user_validation_error', + scope: this.scope, + }; + } + + @todoRouter.onQuery('getTodo') + async getTodoById({ id }: { id: string }) { + if (id === 'eval-error') { + throw new EvalError('Todo evaluation error'); + } + return { + id, + title: 'Sample Todo', + completed: false, + scope: this.scope, + }; + } + + @todoRouter.exceptionHandler(EvalError) + async handleEvalError(error: EvalError) { + return { + message: 'TodoRouter evaluation error', + details: error.message, + type: 'todo_evaluation_error', + scope: this.scope, + }; + } + async handler(event: unknown, context: Context) { + app.includeRouter(userRouter); + app.includeRouter(todoRouter); + return app.resolve(event, context, { + scope: this, + }); + } + } + + const lambda = new Lambda(); + const handler = lambda.handler.bind(lambda); + + // Act + const getUserResult = await handler( + onGraphqlEventFactory('getUser', 'Query', { id: '123' }), + context + ); + const createUserResult = await handler( + onGraphqlEventFactory('createUser', 'Mutation', { + name: 'Jane Doe', + email: 'jane.doe@example.com', + }), + context + ); + const userValidationError = await handler( + onGraphqlEventFactory('getUser', 'Query', { id: '' }), + context + ); + + const getTodoResult = await handler( + onGraphqlEventFactory('getTodo', 'Query', { id: '456' }), + context + ); + const todoEvalError = await handler( + onGraphqlEventFactory('getTodo', 'Query', { id: 'eval-error' }), + context + ); + + // Assess + expect(getUserResult).toEqual({ + id: '123', + name: 'John Doe', + scope: 'scoped', + }); + expect(createUserResult).toEqual({ + id: expect.any(String), + name: 'Jane Doe', + email: 'jane.doe@example.com', + scope: 'scoped', + }); + expect(getTodoResult).toEqual({ + id: '456', + title: 'Sample Todo', + completed: false, + scope: 'scoped', + }); + expect(userValidationError).toEqual({ + message: 'UserRouter validation error', + details: 'User ID cannot be empty', + type: 'user_validation_error', + scope: 'scoped', + }); + expect(todoEvalError).toEqual({ + details: 'Todo evaluation error', + message: 'TodoRouter evaluation error', + type: 'todo_evaluation_error', + scope: 'scoped', + }); + }); + + // #endregion includeRouters + + // #region appendContext + + it('allows sharing context data with resolver handlers', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.onQuery<{ id: string }>( + 'getUser', + async ({ id }, { sharedContext }) => { + const isAdmin = sharedContext?.get('isAdmin'); + const requestId = sharedContext?.get('requestId'); + + return { + id, + name: 'John Doe', + email: isAdmin ? 'john@example.com' : 'hidden', + requestId, + }; + } + ); + + // Act + app.appendContext({ + isAdmin: true, + requestId: 'test-request-123', + timestamp: Date.now(), + }); + + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '1' }), + context + ); + + // Assess + expect(result).toEqual({ + id: '1', + name: 'John Doe', + email: 'john@example.com', + requestId: 'test-request-123', + }); + }); + + it('allows context sharing with included routers', async () => { + // Prepare + const userRouter = new Router(); + userRouter.onQuery<{ id: string }>( + 'getUser', + async ({ id }, { sharedContext }) => { + const isAdmin = sharedContext?.get('isAdmin'); + const requestId = sharedContext?.get('requestId'); + + return { + id, + name: 'John Doe', + role: isAdmin ? 'admin' : 'user', + requestId, + }; + } + ); + + const todoRouter = new Router(); + todoRouter.onQuery<{ id: string }>( + 'getTodo', + async ({ id }, { sharedContext }) => { + const isAdmin = sharedContext?.get('isAdmin'); + const requestId = sharedContext?.get('requestId'); + + return { + id, + title: 'Sample Todo', + completed: false, + role: isAdmin ? 'admin' : 'user', + requestId, + }; + } + ); + + const app = new AppSyncGraphQLResolver(); + app.includeRouter(userRouter); + app.includeRouter(todoRouter); + app.appendContext({ + isAdmin: false, + requestId: 'router-test-456', + }); + + // Act + const userResult = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '2' }), + context + ); + + // Assess + expect(userResult).toEqual({ + id: '2', + name: 'John Doe', + role: 'user', + requestId: 'router-test-456', + }); + }); + + it('clears context after each invocation for single events', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.onQuery<{ id: string }>( + 'getUser', + async ({ id }, { sharedContext }) => { + const requestId = sharedContext?.get('requestId'); + + return { + id, + requestId: requestId || 'no-request-id', + }; + } + ); + + // Act + app.appendContext({ requestId: 'first-request' }); + const firstResult = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '1' }), + context + ); + + // Assess + expect(firstResult).toEqual({ + id: '1', + requestId: 'first-request', + }); + + // Act + const secondResult = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '2' }), + context + ); + + // Assess + expect(secondResult).toEqual({ + id: '2', + requestId: 'no-request-id', + }); + }); + + it('clears context after each invocation for batch events', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.batchResolver<{ id: string }>( + async (events, { sharedContext }) => { + const requestId = sharedContext?.get('requestId'); + + return events.map((event) => ({ + id: event.arguments.id, + requestId: requestId || 'no-request-id', + })); + }, + { + fieldName: 'getUsers', + } + ); + + // Act + app.appendContext({ requestId: 'batch-request' }); + const firstResult = await app.resolve( + [ + onGraphqlEventFactory('getUsers', 'Query', { id: '1' }), + onGraphqlEventFactory('getUsers', 'Query', { id: '2' }), + ], + context + ); + + // Assess + expect(firstResult).toEqual([ + { id: '1', requestId: 'batch-request' }, + { id: '2', requestId: 'batch-request' }, + ]); + + // Act + const secondResult = await app.resolve( + [ + onGraphqlEventFactory('getUsers', 'Query', { id: '3' }), + onGraphqlEventFactory('getUsers', 'Query', { id: '4' }), + ], + context + ); + + // Assess + expect(secondResult).toEqual([ + { id: '3', requestId: 'no-request-id' }, + { id: '4', requestId: 'no-request-id' }, + ]); + }); + + it('allows updating context data multiple times before invocation', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + + app.onQuery<{ id: string }>( + 'getUser', + async ({ id }, { sharedContext }) => { + const role = sharedContext?.get('role'); + const permissions = sharedContext?.get('permissions'); + + return { + id, + role, + permissions, + }; + } + ); + + // Act + app.appendContext({ role: 'user' }); + app.appendContext({ permissions: ['read'] }); + app.appendContext({ role: 'admin' }); + + const result = await app.resolve( + onGraphqlEventFactory('getUser', 'Query', { id: '1' }), + context + ); + + // Assess + expect(result).toEqual({ + id: '1', + role: 'admin', + permissions: ['read'], + }); + }); + + it('does not include sharedContext when context is empty for batch resolvers with throwOnError=true', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + const handlerSpy = vi.fn().mockResolvedValue({ id: '1', processed: true }); + + app.batchResolver(handlerSpy, { + fieldName: 'batchProcess', + aggregate: false, + throwOnError: true, + }); + + // Act + await app.resolve( + [onGraphqlEventFactory('batchProcess', 'Query', { id: '1' })], + context + ); + + // Assess + expect(handlerSpy).toHaveBeenCalledWith( + { id: '1' }, + { + event: expect.any(Object), + context, + } + ); + }); + + it('does not include sharedContext when context is empty for batch resolvers with throwOnError=false', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + const handlerSpy = vi.fn().mockResolvedValue({ id: '1', processed: true }); + + app.batchResolver(handlerSpy, { + fieldName: 'batchProcess', + aggregate: false, + throwOnError: false, + }); + + // Act + await app.resolve( + [onGraphqlEventFactory('batchProcess', 'Query', { id: '1' })], + context + ); + + // Assess + expect(handlerSpy).toHaveBeenCalledWith( + { id: '1' }, + { + event: expect.any(Object), + context, + } + ); + }); + + it('includes sharedContext when context has data for batch resolvers with throwOnError=true', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + const handlerSpy = vi.fn().mockResolvedValue({ id: '1', processed: true }); + app.batchResolver(handlerSpy, { + fieldName: 'batchProcess', + aggregate: false, + throwOnError: true, + }); + + // Act + app.appendContext({ requestId: 'test-123' }); + + await app.resolve( + [onGraphqlEventFactory('batchProcess', 'Query', { id: '1' })], + context + ); + + // Assess + expect(handlerSpy).toHaveBeenCalledWith( + { id: '1' }, + expect.objectContaining({ + event: expect.any(Object), + context, + sharedContext: new Map([['requestId', 'test-123']]), + }) + ); + }); + + it('includes sharedContext when context has data for batch resolvers with throwOnError=false', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + const handlerSpy = vi.fn().mockResolvedValue({ id: '1', processed: true }); + app.batchResolver(handlerSpy, { + fieldName: 'batchProcess', + aggregate: false, + throwOnError: false, + }); + + // Act + app.appendContext({ requestId: 'test-456' }); + await app.resolve( + [onGraphqlEventFactory('batchProcess', 'Query', { id: '1' })], + context + ); + + // Assess + expect(handlerSpy).toHaveBeenCalledWith( + { id: '1' }, + expect.objectContaining({ + event: expect.any(Object), + context, + sharedContext: new Map([['requestId', 'test-456']]), + }) + ); + }); + + // #endregion appendContext });