diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts new file mode 100644 index 000000000..86f38ef20 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -0,0 +1,161 @@ +import type { Context } from 'aws-lambda'; +import type { AppSyncGraphQLEvent } from '../types/appsync-graphql.js'; +import { Router } from './Router.js'; +import { ResolverNotFoundException } from './errors.js'; +import { isAppSyncGraphQLEvent } from './utils.js'; + +/** + * Resolver for AWS AppSync GraphQL APIs. + * + * This resolver is designed to handle the `onQuery` and `onMutation` events + * from AWS AppSync GraphQL APIs. It allows you to register handlers for these events + * and route them to the appropriate functions based on the event's field & type. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onQuery('getPost', async ({ id }) => { + * // your business logic here + * return { + * id, + * title: 'Post Title', + * content: 'Post Content', + * }; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + */ +export class AppSyncGraphQLResolver extends Router { + /** + * Resolve the response based on the provided event and route handlers configured. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onQuery('getPost', async ({ id }) => { + * // your business logic here + * return { + * id, + * title: 'Post Title', + * content: 'Post Content', + * }; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * The method works also as class method decorator, so you can use it like this: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * class Lambda { + * ⁣@app.onQuery('getPost') + * async handleGetPost({ id }) { + * // your business logic here + * return { + * id, + * title: 'Post Title', + * content: 'Post Content', + * }; + * } + * + * async handler(event, context) { + * return app.resolve(event, context); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param event - The incoming event, which may be an AppSync GraphQL event or an array of events. + * @param context - The Lambda execution context. + */ + public async resolve(event: unknown, context: Context): Promise { + if (Array.isArray(event)) { + this.logger.warn('Batch resolver is not implemented yet'); + return; + } + if (!isAppSyncGraphQLEvent(event)) { + this.logger.warn( + 'Received an event that is not compatible with this resolver' + ); + return; + } + try { + return await this.#executeSingleResolver(event); + } catch (error) { + this.logger.error( + `An error occurred in handler ${event.info.fieldName}`, + error + ); + if (error instanceof ResolverNotFoundException) throw error; + return this.#formatErrorResponse(error); + } + } + + /** + * Executes the appropriate resolver (query or mutation) for a given AppSync GraphQL event. + * + * This method attempts to resolve the handler for the specified field and type name + * from the query and mutation registries. If a matching handler is found, it invokes + * the handler with the event arguments. If no handler is found, it throws a + * `ResolverNotFoundException`. + * + * @param event - The AppSync GraphQL event containing resolver information. + * @throws {ResolverNotFoundException} If no resolver is registered for the given field and type. + */ + async #executeSingleResolver(event: AppSyncGraphQLEvent): Promise { + const { fieldName, parentTypeName: typeName } = event.info; + const queryHandlerOptions = this.onQueryRegistry.resolve( + typeName, + fieldName + ); + if (queryHandlerOptions) { + return await queryHandlerOptions.handler.apply(this, [event.arguments]); + } + + const mutationHandlerOptions = this.onMutationRegistry.resolve( + typeName, + fieldName + ); + if (mutationHandlerOptions) { + return await mutationHandlerOptions.handler.apply(this, [ + event.arguments, + ]); + } + + throw new ResolverNotFoundException( + `No resolver found for ${typeName}-${fieldName}` + ); + } + + /** + * Format the error response to be returned to the client. + * + * @param error - The error object + */ + #formatErrorResponse(error: unknown) { + if (error instanceof Error) { + return { + error: `${error.name} - ${error.message}`, + }; + } + return { + error: 'An unknown error occurred', + }; + } +} diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts new file mode 100644 index 000000000..78dcf9264 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -0,0 +1,86 @@ +import type { + GenericLogger, + RouteHandlerOptions, + RouteHandlerRegistryOptions, +} from '../types/appsync-graphql.js'; + +/** + * Registry for storing route handlers for the `query` and `mutation` events in AWS AppSync GraphQL API's. + * + * This class should not be used directly unless you are implementing a custom router. + * Instead, use the {@link Router} class, which is the recommended way to register routes. + */ +class RouteHandlerRegistry { + /** + * A map of registered route handlers, keyed by their type & field name. + */ + protected readonly resolvers: Map = new Map(); + /** + * A logger instance to be used for logging debug and warning messages. + */ + readonly #logger: GenericLogger; + /** + * The event type stored in the registry. + */ + readonly #eventType: 'onQuery' | 'onMutation'; + + public constructor(options: RouteHandlerRegistryOptions) { + this.#logger = options.logger; + this.#eventType = options.eventType ?? 'onQuery'; + } + + /** + * Registers a new GraphQL route resolver for a specific type and field. + * + * @param options - The options for registering the route handler, including the GraphQL type name, field name, and the handler function. + * @param options.fieldName - The field name of the GraphQL type to be registered + * @param options.handler - The handler function to be called when the GraphQL event is received + * @param options.typeName - The name of the GraphQL type to be registered + * + */ + public register(options: RouteHandlerOptions): void { + const { fieldName, handler, typeName } = options; + this.#logger.debug( + `Adding ${this.#eventType} 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.resolvers.set(cacheKey, { + fieldName, + handler, + typeName, + }); + } + + /** + * Resolves the handler for a specific GraphQL API event. + * + * @param typeName - The name of the GraphQL type (e.g., "Query", "Mutation", or a custom type). + * @param fieldName - The name of the field within the specified type. + */ + public resolve( + typeName: string, + fieldName: string + ): RouteHandlerOptions | undefined { + this.#logger.debug( + `Looking for ${this.#eventType} resolver for type=${typeName}, field=${fieldName}` + ); + return this.resolvers.get(this.#makeKey(typeName, fieldName)); + } + + /** + * Generates a unique key by combining the provided GraphQL type name and field name. + * + * @param typeName - The name of the GraphQL type. + * @param fieldName - The name of the GraphQL field. + */ + #makeKey(typeName: string, fieldName: string): string { + return `${typeName}.${fieldName}`; + } +} + +export { RouteHandlerRegistry }; diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts new file mode 100644 index 000000000..8bf30b5d6 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -0,0 +1,233 @@ +import { + EnvironmentVariablesService, + isRecord, +} from '@aws-lambda-powertools/commons'; +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import { getStringFromEnv } from '@aws-lambda-powertools/commons/utils/env'; +import type { + GraphQlRouteOptions, + GraphQlRouterOptions, + OnMutationHandler, + OnQueryHandler, +} from '../types/appsync-graphql.js'; +import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; + +/** + * Class for registering routes for the `query` and `mutation` events in AWS AppSync GraphQL APIs. + */ +class Router { + /** + * A map of registered routes for the `query` event, keyed by their fieldNames. + */ + protected readonly onQueryRegistry: RouteHandlerRegistry; + /** + * A map of registered routes for the `mutation` event, keyed by their fieldNames. + */ + protected readonly onMutationRegistry: RouteHandlerRegistry; + /** + * 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. + */ + protected readonly logger: Pick; + /** + * Whether the router is running in development mode. + */ + protected readonly isDev: boolean = false; + /** + * The environment variables service instance. + */ + protected readonly envService: EnvironmentVariablesService; + + public constructor(options?: GraphQlRouterOptions) { + this.envService = new EnvironmentVariablesService(); + const alcLogLevel = getStringFromEnv({ + key: 'AWS_LAMBDA_LOG_LEVEL', + defaultValue: '', + }); + this.logger = options?.logger ?? { + debug: alcLogLevel === 'DEBUG' ? console.debug : () => undefined, + error: console.error, + warn: console.warn, + }; + this.onQueryRegistry = new RouteHandlerRegistry({ + logger: this.logger, + eventType: 'onQuery', + }); + this.onMutationRegistry = new RouteHandlerRegistry({ + logger: this.logger, + eventType: 'onMutation', + }); + this.isDev = this.envService.isDevMode(); + } + + /** + * Register a handler function for the `query` event. + + * Registers a handler for a specific GraphQL Query field. The handler will be invoked when a request is made + * for the specified field in the Query type. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onQuery('getPost', async (payload) => { + * // your business logic here + * return payload; + * }); + + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * As a decorator: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * class Lambda { + * @app.onQuery('getPost') + * async handleGetPost(payload) { + * // your business logic here + * return payload; + * } + * + * async handler(event, context) { + * return app.resolve(event, context); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param fieldName - The name of the Query field to register the handler for. + * @param handler - The handler function to be called when the event is received. + * @param options - Optional route options. + * @param options.typeName - The name of the GraphQL type to use for the resolver (defaults to 'Query'). + */ + public onQuery( + fieldName: string, + handler: OnQueryHandler, + options?: GraphQlRouteOptions + ): void; + public onQuery( + fieldName: string, + options?: GraphQlRouteOptions + ): MethodDecorator; + public onQuery( + fieldName: string, + handler?: OnQueryHandler | GraphQlRouteOptions, + options?: GraphQlRouteOptions + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.onQueryRegistry.register({ + fieldName, + handler, + typeName: options?.typeName ?? 'Query', + }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const routeOptions = isRecord(handler) ? handler : options; + this.onQueryRegistry.register({ + fieldName, + handler: descriptor.value, + typeName: routeOptions?.typeName ?? 'Query', + }); + return descriptor; + }; + } + + /** + * Register a handler function for the `mutation` event. + * + * Registers a handler for a specific GraphQL Mutation field. The handler will be invoked when a request is made + * for the specified field in the Mutation type. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onMutation('createPost', async (payload) => { + * // your business logic here + * return payload; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * As a decorator: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * class Lambda { + * @app.onMutation('createPost') + * async handleCreatePost(payload) { + * // your business logic here + * return payload; + * } + * + * async handler(event, context) { + * return app.resolve(event, context); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param fieldName - The name of the Mutation field to register the handler for. + * @param handler - The handler function to be called when the event is received. + * @param options - Optional route options. + * @param options.typeName - The name of the GraphQL type to use for the resolver (defaults to 'Mutation'). + */ + public onMutation( + fieldName: string, + handler: OnMutationHandler, + options?: GraphQlRouteOptions + ): void; + public onMutation( + fieldName: string, + options?: GraphQlRouteOptions + ): MethodDecorator; + public onMutation( + fieldName: string, + handler?: OnMutationHandler | GraphQlRouteOptions, + options?: GraphQlRouteOptions + ): MethodDecorator | undefined { + if (handler && typeof handler === 'function') { + this.onMutationRegistry.register({ + fieldName, + handler, + typeName: options?.typeName ?? 'Mutation', + }); + return; + } + + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const routeOptions = isRecord(handler) ? handler : options; + this.onMutationRegistry.register({ + fieldName, + handler: descriptor.value, + typeName: routeOptions?.typeName ?? 'Mutation', + }); + return descriptor; + }; + } +} + +export { Router }; diff --git a/packages/event-handler/src/appsync-graphql/errors.ts b/packages/event-handler/src/appsync-graphql/errors.ts new file mode 100644 index 000000000..b3f3c15b9 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/errors.ts @@ -0,0 +1,8 @@ +class ResolverNotFoundException extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ResolverNotFoundException'; + } +} + +export { ResolverNotFoundException }; diff --git a/packages/event-handler/src/appsync-graphql/utils.ts b/packages/event-handler/src/appsync-graphql/utils.ts new file mode 100644 index 000000000..e925d6b32 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -0,0 +1,44 @@ +import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; +import type { AppSyncGraphQLEvent } from '../types/appsync-graphql.js'; + +/** + * Type guard to check if the provided event is an AppSync GraphQL event. + * + * We use this function to ensure that the event is an object and has the required properties + * without adding any dependency. + * + * @param event - The incoming event to check + */ +const isAppSyncGraphQLEvent = ( + event: unknown +): event is AppSyncGraphQLEvent => { + if (typeof event !== 'object' || event === null || !isRecord(event)) { + return false; + } + return ( + 'arguments' in event && + isRecord(event.arguments) && + 'identity' in event && + 'source' in event && + isRecord(event.request) && + isRecord(event.request.headers) && + 'domainName' in event.request && + 'prev' in event && + isRecord(event.info) && + 'fieldName' in event.info && + isString(event.info.fieldName) && + 'parentTypeName' in event.info && + isString(event.info.parentTypeName) && + 'variables' in event.info && + isRecord(event.info.variables) && + 'selectionSetList' in event.info && + Array.isArray(event.info.selectionSetList) && + event.info.selectionSetList.every((item) => isString(item)) && + 'parentTypeName' in event.info && + isString(event.info.parentTypeName) && + 'stash' in event && + isRecord(event.stash) + ); +}; + +export { isAppSyncGraphQLEvent }; diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts new file mode 100644 index 000000000..ad9b81d42 --- /dev/null +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -0,0 +1,126 @@ +import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; +import type { Anything, GenericLogger } from './common.js'; + +// #region OnQuery fn + +type OnQuerySyncHandlerFn = ({ ...args }: Anything) => unknown; + +type OnQueryHandlerFn = ({ ...args }: Anything) => Promise; + +type OnQueryHandler = OnQuerySyncHandlerFn | OnQueryHandlerFn; + +// #region OnMutation fn + +type OnMutationSyncHandlerFn = ({ ...args }: Anything) => unknown; + +type OnMutationHandlerFn = ({ ...args }: Anything) => Promise; + +type OnMutationHandler = OnMutationSyncHandlerFn | OnMutationHandlerFn; + +// #region Resolver registry + +/** + * Options for the {@link RouteHandlerRegistry} class + */ +type RouteHandlerRegistryOptions = { + /** + * 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: GenericLogger; + /** + * Event type stored in the registry + * @default 'onQuery' + */ + eventType?: 'onQuery' | 'onMutation'; +}; + +/** + * Options for registering a resolver event + * + * @property handler - The handler function to be called when the event is received + * @property fieldName - The name of the field to be registered + * @property typeName - The name of the type to be registered + */ +type RouteHandlerOptions = { + /** + * The handler function to be called when the event is received + */ + handler: OnQueryHandler | OnMutationHandler; + /** + * The field name of the event to be registered + */ + fieldName: string; + /** + * The type name of the event to be registered + */ + typeName: string; +}; + +// #region Router + +/** + * Options for the {@link Router} class + */ +type GraphQlRouterOptions = { + /** + * 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?: GenericLogger; +}; + +/** + * Options for registering a route + */ +type GraphQlRouteOptions = { + /** + * The type name of the event to be registered + */ + typeName?: string; +}; + +// #region Events + +/** + * Event type for AppSync GraphQL. + * + * https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html + * + * For strongly typed validation and parsing at runtime, check out the `@aws-lambda-powertools/parser` package. + */ +type AppSyncGraphQLEvent = { + arguments: Record; + /** + * The `identity` field varies based on the authentication type used for the AppSync API. + * When using an API key, it will be `null`. When using IAM, it will contain the AWS credentials of the user. When using Cognito, + * it will contain the Cognito user pool information. When using a Lambda authorizer, it will contain the information returned + * by the authorizer. + */ + identity: null | Record; + source: null | Record; + request: { + headers: Record; + domainName: null; + }; + prev: null; + info: { + fieldName: string; + selectionSetList: string[]; + parentTypeName: string; + }; + stash: Record; +}; + +export type { + GenericLogger, + RouteHandlerRegistryOptions, + RouteHandlerOptions, + GraphQlRouterOptions, + GraphQlRouteOptions, + AppSyncGraphQLEvent, + OnQueryHandler, + OnMutationHandler, +}; diff --git a/packages/event-handler/tests/helpers/factories.ts b/packages/event-handler/tests/helpers/factories.ts index a439da81a..149f08e9e 100644 --- a/packages/event-handler/tests/helpers/factories.ts +++ b/packages/event-handler/tests/helpers/factories.ts @@ -74,4 +74,45 @@ const onSubscribeEventFactory = ( events: null, }); -export { onPublishEventFactory, onSubscribeEventFactory }; +const createEventFactory = ( + fieldName: string, + args: Record, + parentTypeName: string +) => ({ + arguments: { ...args }, + identity: null, + source: null, + request: { + headers: { + key: 'value', + }, + domainName: null, + }, + info: { + fieldName, + parentTypeName, + selectionSetList: [], + variables: {}, + }, + prev: null, + stash: {}, +}); + +const onQueryEventFactory = ( + fieldName = 'getPost', + args = {}, + typeName = 'Query' +) => createEventFactory(fieldName, args, typeName); + +const onMutationEventFactory = ( + fieldName = 'addPost', + args = {}, + typeName = 'Mutation' +) => createEventFactory(fieldName, args, typeName); + +export { + onPublishEventFactory, + onSubscribeEventFactory, + onQueryEventFactory, + onMutationEventFactory, +}; diff --git a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts new file mode 100644 index 000000000..cd781224e --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -0,0 +1,174 @@ +import context from '@aws-lambda-powertools/testing-utils/context'; +import { + onMutationEventFactory, + onQueryEventFactory, +} from 'tests/helpers/factories.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; +import { ResolverNotFoundException } from '../../../src/appsync-graphql/errors.js'; + +describe('Class: AppSyncGraphQLResolver', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('logs a warning and returns early if the event is batched', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act + const result = await app.resolve([onQueryEventFactory()], context); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Batch resolver is not implemented yet' + ); + expect(result).toBeUndefined(); + }); + + it('logs a warning and returns early if the event is not compatible', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act + const result = await app.resolve(null, context); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Received an event that is not compatible with this resolver' + ); + expect(result).toBeUndefined(); + }); + + it('throw error if there are no onQuery handlers', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act && Assess + await expect( + app.resolve(onQueryEventFactory('getPost'), context) + ).rejects.toThrow( + new ResolverNotFoundException('No resolver found for Query-getPost') + ); + expect(console.error).toHaveBeenCalled(); + }); + + it('throw error if there are no onMutation handlers', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act && Assess + await expect( + app.resolve(onMutationEventFactory('addPost'), context) + ).rejects.toThrow( + new ResolverNotFoundException('No resolver found for Mutation-addPost') + ); + expect(console.error).toHaveBeenCalled(); + }); + + it('returns the response of the onQuery handler', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + app.onQuery('getPost', async ({ id }) => { + return { + id, + title: 'Post Title', + content: 'Post Content', + }; + }); + + // Act + const result = await app.resolve( + onQueryEventFactory('getPost', { id: '123' }), + context + ); + + // Assess + expect(result).toEqual({ + id: '123', + title: 'Post Title', + content: 'Post Content', + }); + }); + + it('returns the response of the onMutation handler', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + app.onMutation('addPost', async ({ title, content }) => { + return { + id: '123', + title, + content, + }; + }); + + // Act + const result = await app.resolve( + onMutationEventFactory('addPost', { + title: 'Post Title', + content: 'Post Content', + }), + context + ); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding onMutation resolver for field Mutation.addPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Looking for onQuery resolver for type=Mutation, field=addPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 3, + 'Looking for onMutation resolver for type=Mutation, field=addPost' + ); + expect(result).toEqual({ + id: '123', + title: 'Post Title', + content: 'Post Content', + }); + }); + + it.each([ + { + type: 'base error', + error: new Error('Error in handler'), + message: 'Error - Error in handler', + }, + { + type: 'syntax error', + error: new SyntaxError('Syntax error in handler'), + message: 'SyntaxError - Syntax error in handler', + }, + { + type: 'unknown error', + error: 'foo', + message: 'An unknown error occurred', + }, + ])( + 'formats the error thrown by the onSubscribe handler $type', + async ({ error, message }) => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + app.onMutation('addPost', async () => { + throw error; + }); + + // Act + const result = await app.resolve( + onMutationEventFactory('addPost', { + title: 'Post Title', + content: 'Post Content', + }), + context + ); + + // Assess + expect(result).toEqual({ + error: message, + }); + } + ); +}); diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts new file mode 100644 index 000000000..b333b91d4 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RouteHandlerRegistry } from '../../../src/appsync-graphql/RouteHandlerRegistry.js'; +import type { RouteHandlerOptions } from '../../../src/types/appsync-graphql.js'; +describe('Class: RouteHandlerRegistry', () => { + class MockRouteHandlerRegistry extends RouteHandlerRegistry { + public declare resolvers: Map; + } + + const getRegistry = () => new MockRouteHandlerRegistry({ logger: console }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { fieldName: 'getPost', typeName: 'Query' }, + { fieldName: 'addPost', typeName: 'Mutation' }, + ])( + 'registers a route handler for a field $fieldName', + ({ fieldName, typeName }) => { + // Prepare + const registry = getRegistry(); + + // Act + registry.register({ + fieldName, + typeName, + handler: vi.fn(), + }); + + // Assess + expect(registry.resolvers.size).toBe(1); + expect(registry.resolvers.get(`${typeName}.${fieldName}`)).toBeDefined(); + } + ); + + it('logs a warning and replaces the previous resolver if the field & type is already registered', () => { + // Prepare + const registry = getRegistry(); + const originalHandler = vi.fn(); + const otherHandler = vi.fn(); + + // Act + registry.register({ + fieldName: 'getPost', + typeName: 'Query', + handler: originalHandler, + }); + registry.register({ + fieldName: 'getPost', + typeName: 'Query', + handler: otherHandler, + }); + + // Assess + expect(registry.resolvers.size).toBe(1); + expect(registry.resolvers.get('Query.getPost')).toEqual({ + fieldName: 'getPost', + typeName: 'Query', + handler: otherHandler, + }); + expect(console.warn).toHaveBeenCalledWith( + "A resolver for field 'getPost' is already registered for 'Query'. The previous resolver will be replaced." + ); + }); + + it('will not replace the resolver if the event type is different', () => { + // Prepare + const registry = getRegistry(); + const originalHandler = vi.fn(); + const otherHandler = vi.fn(); + + // Act + registry.register({ + fieldName: 'getPost', + typeName: 'Query', + handler: originalHandler, + }); + registry.register({ + fieldName: 'getPost', + typeName: 'Mutation', // Different type + handler: otherHandler, + }); + + // Assess + expect(registry.resolvers.size).toBe(2); + expect(registry.resolvers.get('Query.getPost')).toEqual({ + fieldName: 'getPost', + typeName: 'Query', + handler: originalHandler, + }); + expect(registry.resolvers.get('Mutation.getPost')).toEqual({ + fieldName: 'getPost', + typeName: 'Mutation', + handler: otherHandler, + }); + }); +}); diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts new file mode 100644 index 000000000..86878bebf --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -0,0 +1,144 @@ +import { Router } from 'src/appsync-graphql/Router.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('Class: Router', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers resolvers using the functional approach', () => { + // Prepare + const router = new Router({ logger: console }); + const getPost = vi.fn(() => [true]); + const addPost = vi.fn(async () => true); + + // Act + router.onQuery('getPost', getPost, { typeName: 'Query' }); + router.onMutation('addPost', addPost, { typeName: 'Mutation' }); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding onQuery resolver for field Query.getPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding onMutation resolver for field Mutation.addPost' + ); + }); + + it('registers resolvers using the decorator pattern', () => { + // Prepare + const router = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @router.onQuery('getPost') + public getPost() { + return `${this.prop} foo`; + } + + @router.onQuery('getAuthor', { typeName: 'Query' }) + public getAuthor() { + return `${this.prop} bar`; + } + + @router.onMutation('addPost') + public addPost() { + return `${this.prop} bar`; + } + + @router.onMutation('updatePost', { typeName: 'Mutation' }) + public updatePost() { + return `${this.prop} baz`; + } + } + const lambda = new Lambda(); + const res1 = lambda.getPost(); + const res2 = lambda.getAuthor(); + const res3 = lambda.addPost(); + const res4 = lambda.updatePost(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding onQuery resolver for field Query.getPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding onQuery resolver for field Query.getAuthor' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 3, + 'Adding onMutation resolver for field Mutation.addPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 4, + 'Adding onMutation resolver for field Mutation.updatePost' + ); + + // verify that class scope is preserved after decorating + expect(res1).toBe('value foo'); + expect(res2).toBe('value bar'); + expect(res3).toBe('value bar'); + expect(res4).toBe('value baz'); + }); + + it('registers nested resolvers using the decorator pattern', () => { + // Prepare + const router = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @router.onQuery('listLocations') + @router.onQuery('locations') + public getLocations() { + return [{ name: 'Location 1', description: 'Description 1' }]; + } + } + const lambda = new Lambda(); + const response = lambda.getLocations(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding onQuery resolver for field Query.locations' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding onQuery resolver for field Query.listLocations' + ); + + expect(response).toEqual([ + { name: 'Location 1', description: 'Description 1' }, + ]); + }); + + it('uses a default logger with only warnings if none is provided', () => { + // Prepare + const router = new Router(); + + // Act + router.onQuery('getPost', vi.fn()); + + // Assess + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('emits debug messages when ALC_LOG_LEVEL is set to DEBUG', () => { + // Prepare + process.env.AWS_LAMBDA_LOG_LEVEL = 'DEBUG'; + const router = new Router(); + + // Act + router.onQuery('getPost', vi.fn()); + + // Assess + expect(console.debug).toHaveBeenCalled(); + process.env.AWS_LAMBDA_LOG_LEVEL = undefined; + }); +});