diff --git a/docs/features/event-handler/appsync-graphql.md b/docs/features/event-handler/appsync-graphql.md index 9112da2c01..fc97428b55 100644 --- a/docs/features/event-handler/appsync-graphql.md +++ b/docs/features/event-handler/appsync-graphql.md @@ -114,6 +114,36 @@ 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" + ``` + ### Nested mappings !!! note 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/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/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 6aede4c554..cb3c9a616c 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -180,6 +180,50 @@ class AppSyncGraphQLResolver extends Router { ); } + /** + * 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. + * + * **Note:** When multiple routers register handlers for the same type and field combination + * (e.g., both `userRouter` and `postRouter` define `Query.getPost`), the handler from the + * last included router takes precedence and will override earlier registrations. + * This behavior also applies to exception handlers registered for the same error class. + * A warning is logged to help you identify potential conflicts when handlers are overridden. + * + * @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'); + } + /** * Executes the provided asynchronous function with error handling. * If the function throws an error, it delegates error processing to `#handleError` 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/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts index 1100a41005..15b363a22a 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,362 @@ 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( + () => { + 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( + () => { + 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', () => { + 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', () => { + 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') + 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') + createUser({ name, email }: { name: string; email: string }) { + return { id: makeId(), name, email, scope: this.scope }; + } + + @userRouter.exceptionHandler(ValidationError) + handleValidationError(error: ValidationError) { + return { + message: 'UserRouter validation error', + details: error.message, + type: 'user_validation_error', + scope: this.scope, + }; + } + + @todoRouter.onQuery('getTodo') + 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) + handleEvalError(error: EvalError) { + return { + message: 'TodoRouter evaluation error', + details: error.message, + type: 'todo_evaluation_error', + scope: this.scope, + }; + } + 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 });