From 8c91a44d8776e7b66c0790d1f5b45da6e776869f Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 25 May 2025 15:09:50 +0600 Subject: [PATCH 01/49] feat: implement `RouteHandlerRegistry` for managing GraphQL route handlers --- .../appsync-graphql/RouteHandlerRegistry.ts | 108 +++++++++++++ .../src/types/appsync-graphql.ts | 153 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts create mode 100644 packages/event-handler/src/types/appsync-graphql.ts 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 0000000000..9465078390 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -0,0 +1,108 @@ +import { LRUCache } from '@aws-lambda-powertools/commons/utils/lru-cache'; +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'; + /** + * A cache for storing the resolved route handlers. + */ + readonly #resolverCache: LRUCache = new LRUCache( + { + maxSize: 100, + } + ); + /** + * A set of warning messages to avoid duplicate warnings. + */ + readonly #warningSet: Set = new Set(); + + public constructor(options: RouteHandlerRegistryOptions) { + this.#logger = options.logger; + this.#eventType = options.eventType ?? 'onQuery'; + } + + /** + * Registers a new GraphQL route handler 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. + * + * @remarks + * This method logs the registration and stores the handler in the internal resolver map. + */ + public register(options: RouteHandlerOptions): void { + const { fieldName, handler, typeName } = options; + this.#logger.debug( + `Registering ${typeName} api handler for field '${fieldName}'` + ); + this.resolvers.set(this.#makeKey(typeName, fieldName), { + fieldName, + handler, + typeName, + }); + } + + /** + * Resolves the handler for a specific GraphQL API event. + * + * This method first checks an internal cache for the handler. If not found, it attempts to retrieve + * the handler from the registered resolvers. If the handler is still not found, a warning is logged + * (only once per missing handler), and `undefined` is returned. + * + * @param typeName - The name of the GraphQL type. + * @param fieldName - The name of the field within the GraphQL type. + */ + public resolve( + typeName: string, + fieldName: string + ): RouteHandlerOptions | undefined { + const cacheKey = this.#makeKey(typeName, fieldName); + if (this.#resolverCache.has(cacheKey)) { + return this.#resolverCache.get(cacheKey); + } + const handler = this.resolvers.get(cacheKey); + if (handler === undefined) { + if (!this.#warningSet.has(cacheKey)) { + this.#logger.warn( + `No route handler found for field '${fieldName}' registered for ${this.#eventType}.` + ); + this.#warningSet.add(cacheKey); + } + return undefined; + } + this.#resolverCache.add(cacheKey, handler); + return handler; + } + + /** + * 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/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts new file mode 100644 index 0000000000..101763ec9a --- /dev/null +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -0,0 +1,153 @@ +import type { Context } from 'aws-lambda'; +import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; + +// biome-ignore lint/suspicious/noExplicitAny: We intentionally use `any` here to represent any type of data and keep the logger is as flexible as possible. +type Anything = any; + +/** + * Interface for a generic logger object. + */ +type GenericLogger = { + trace?: (...content: Anything[]) => void; + debug: (...content: Anything[]) => void; + info?: (...content: Anything[]) => void; + warn: (...content: Anything[]) => void; + error: (...content: Anything[]) => void; +}; + +// #region OnQuery fn + +type OnQuerySyncHandlerFn = ( + event: AppSyncGraphQLEvent, + context: Context +) => unknown; + +type OnQueryHandlerFn = ( + event: AppSyncGraphQLEvent, + context: Context +) => Promise; + +type OnQueryHandler = OnQuerySyncHandlerFn | OnQueryHandlerFn; + +// #region OnMutation fn + +type OnMutationSyncHandlerFn = ( + event: AppSyncGraphQLEvent, + context: Context +) => unknown; + +type OnMutationHandlerFn = ( + event: AppSyncGraphQLEvent, + context: Context +) => 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 RouterOptions = { + /** + * 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 RouteOptions = { + /** + * 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; + result: null; + request: { + headers: Record; + domainName: null; + }; + prev: null; + info: { + fieldName: string; + selectionSetList: string[]; + parentTypeName: string; + }; + stash: Record; +}; + +export type { + GenericLogger, + RouteHandlerRegistryOptions, + RouteHandlerOptions, + RouterOptions, + RouteOptions, + AppSyncGraphQLEvent, + OnQueryHandler, + OnMutationHandler, +}; From 34e554862de1b6182f1562b8132cfa03d40ad9dc Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 25 May 2025 15:17:07 +0600 Subject: [PATCH 02/49] feat: add type guard for AppSync GraphQL event validation --- .../src/appsync-graphql/utils.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/event-handler/src/appsync-graphql/utils.ts 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 0000000000..86f4fb920b --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -0,0 +1,45 @@ +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 && + 'result' 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 }; From d98d507c00f1272d8004a726bac481e5b66aa22e Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 25 May 2025 17:54:12 +0600 Subject: [PATCH 03/49] refactor: simplify handler function signatures and update type definitions for GraphQL events --- .../src/types/appsync-graphql.ts | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 101763ec9a..331d5d4245 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,4 +1,3 @@ -import type { Context } from 'aws-lambda'; import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; // biome-ignore lint/suspicious/noExplicitAny: We intentionally use `any` here to represent any type of data and keep the logger is as flexible as possible. @@ -17,29 +16,17 @@ type GenericLogger = { // #region OnQuery fn -type OnQuerySyncHandlerFn = ( - event: AppSyncGraphQLEvent, - context: Context -) => unknown; +type OnQuerySyncHandlerFn = ({ ...args }: Anything) => unknown; -type OnQueryHandlerFn = ( - event: AppSyncGraphQLEvent, - context: Context -) => Promise; +type OnQueryHandlerFn = ({ ...args }: Anything) => Promise; type OnQueryHandler = OnQuerySyncHandlerFn | OnQueryHandlerFn; // #region OnMutation fn -type OnMutationSyncHandlerFn = ( - event: AppSyncGraphQLEvent, - context: Context -) => unknown; +type OnMutationSyncHandlerFn = ({ ...args }: Anything) => unknown; -type OnMutationHandlerFn = ( - event: AppSyncGraphQLEvent, - context: Context -) => Promise; +type OnMutationHandlerFn = ({ ...args }: Anything) => Promise; type OnMutationHandler = OnMutationSyncHandlerFn | OnMutationHandlerFn; @@ -89,7 +76,7 @@ type RouteHandlerOptions = { /** * Options for the {@link Router} class */ -type RouterOptions = { +type GraphQlRouterOptions = { /** * A logger instance to be used for logging debug, warning, and error messages. * @@ -101,7 +88,7 @@ type RouterOptions = { /** * Options for registering a route */ -type RouteOptions = { +type GraphQlRouteOptions = { /** * The type name of the event to be registered */ @@ -127,7 +114,6 @@ type AppSyncGraphQLEvent = { */ identity: null | Record; source: null | Record; - result: null; request: { headers: Record; domainName: null; @@ -145,8 +131,8 @@ export type { GenericLogger, RouteHandlerRegistryOptions, RouteHandlerOptions, - RouterOptions, - RouteOptions, + GraphQlRouterOptions, + GraphQlRouteOptions, AppSyncGraphQLEvent, OnQueryHandler, OnMutationHandler, From a2d4c7ab8d6718a151ce417b3716593da4241c28 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 25 May 2025 17:54:38 +0600 Subject: [PATCH 04/49] refactor: remove wrong `result` property check from AppSync GraphQL event type guard --- packages/event-handler/src/appsync-graphql/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/event-handler/src/appsync-graphql/utils.ts b/packages/event-handler/src/appsync-graphql/utils.ts index 86f4fb920b..e925d6b324 100644 --- a/packages/event-handler/src/appsync-graphql/utils.ts +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -20,7 +20,6 @@ const isAppSyncGraphQLEvent = ( isRecord(event.arguments) && 'identity' in event && 'source' in event && - 'result' in event && isRecord(event.request) && isRecord(event.request.headers) && 'domainName' in event.request && From 0134b3d37c55b4090f8baa88a710db914b75e922 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 28 May 2025 10:11:30 +0600 Subject: [PATCH 05/49] feat: implement Router class for managing `Query` events for appsync graphql --- .../src/appsync-graphql/Router.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 packages/event-handler/src/appsync-graphql/Router.ts 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 0000000000..b759131ad6 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -0,0 +1,145 @@ +import { + EnvironmentVariablesService, + isRecord, +} from '@aws-lambda-powertools/commons'; +import type { + GenericLogger, + GraphQlRouteOptions, + GraphQlRouterOptions, + OnQueryHandler, +} from '../types/appsync-graphql.js'; +import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; + +/** + * Class for registering routes for the `onQuery` and `onMutation` events in AWS AppSync Events APIs. + */ +class Router { + /** + * A map of registered routes for the `onQuery` event, keyed by their fieldNames. + */ + protected readonly onQueryRegistry: RouteHandlerRegistry; + /** + * A map of registered routes for the `onMutation` 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 = this.envService.get('AWS_LAMBDA_LOG_LEVEL'); + 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 `onQuery` 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. + * + * This method can be used as a direct function call or as a method decorator. + * + * @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 function to handle the Query. Receives the payload as the first argument. + * @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; + }; + } +} + +export { Router }; From 84d55d476f01f5948ee70bc6c0f7ca6439395803 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 28 May 2025 10:15:37 +0600 Subject: [PATCH 06/49] feat: add `onMutation` method for handling GraphQL Mutation events in Router class --- .../src/appsync-graphql/Router.ts | 96 +++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index b759131ad6..548b3d1e53 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -6,6 +6,7 @@ import type { GenericLogger, GraphQlRouteOptions, GraphQlRouterOptions, + OnMutationHandler, OnQueryHandler, } from '../types/appsync-graphql.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; @@ -55,14 +56,13 @@ class Router { }); this.isDev = this.envService.isDevMode(); } + /** * Register a handler function for the `onQuery` 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. - * - * This method can be used as a direct function call or as a method decorator. - * + * * @example * ```ts * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; @@ -73,7 +73,7 @@ class Router { * // your business logic here * return payload; * }); - * + * export const handler = async (event, context) => * app.resolve(event, context); * ``` @@ -103,7 +103,7 @@ class Router { * ``` * * @param fieldName - The name of the Query field to register the handler for. - * @param handler - The function to handle the Query. Receives the payload as the first argument. + * @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'). */ @@ -140,6 +140,90 @@ class Router { return descriptor; }; } + + /** + * Register a handler function for the `onMutation` 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 }; From febd4ce0682383a4cc15701ac16867909a486915 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 28 May 2025 10:41:01 +0600 Subject: [PATCH 07/49] feat: implement AppSyncGraphQLResolver class to handle onQuery and onMutation events --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 98 +++++++++++++++++++ .../src/appsync-graphql/errors.ts | 8 ++ 2 files changed, 106 insertions(+) create mode 100644 packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts create mode 100644 packages/event-handler/src/appsync-graphql/errors.ts 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 0000000000..c38490b5eb --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -0,0 +1,98 @@ +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 { + public async resolve(event: unknown, context: Context): Promise { + if (!isAppSyncGraphQLEvent(event)) { + this.logger.warn( + 'Received an event that is not compatible with this resolver' + ); + return; + } + + try { + if (Array.isArray(event)) { + this.logger.warn('Batch resolvers not implemented yet'); + } else { + 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); + } + } + + async #executeSingleResolver(event: AppSyncGraphQLEvent): Promise { + const { fieldName, parentTypeName: typeName } = event.info; + const queryHandlerOptions = this.onQueryRegistry.resolve( + typeName, + fieldName + ); + const mutationHandlerOptions = this.onMutationRegistry.resolve( + typeName, + fieldName + ); + + if (queryHandlerOptions) { + return await queryHandlerOptions.handler.apply(this, [event.arguments]); + } + if (mutationHandlerOptions) { + return await mutationHandlerOptions.handler.apply(this, [ + event.arguments, + ]); + } + + throw new ResolverNotFoundException( + `No resolver found for the event ${fieldName}-${typeName}.` + ); + } + + /** + * 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/errors.ts b/packages/event-handler/src/appsync-graphql/errors.ts new file mode 100644 index 0000000000..b3f3c15b97 --- /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 }; From 3e5d5047b496b55e55c177e7cd554065c411e213 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 28 May 2025 10:42:01 +0600 Subject: [PATCH 08/49] doc: `#executeSingleResolver` function --- .../src/appsync-graphql/AppSyncGraphQLResolver.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index c38490b5eb..c388ba4456 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -55,6 +55,17 @@ export class AppSyncGraphQLResolver extends Router { } } + /** + * 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( From 17a0c551c9fe2fbd0e3b7702c1aa1b2deb05006d Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 28 May 2025 10:54:48 +0600 Subject: [PATCH 09/49] feat: add warning for unimplemented batch resolvers in AppSyncGraphQLResolver --- .../src/appsync-graphql/AppSyncGraphQLResolver.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index c388ba4456..503c3fb480 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -32,19 +32,18 @@ import { isAppSyncGraphQLEvent } from './utils.js'; */ export class AppSyncGraphQLResolver extends Router { public async resolve(event: unknown, context: Context): Promise { + if (Array.isArray(event)) { + this.logger.warn('Batch resolvers are not implemented yet'); + return; + } if (!isAppSyncGraphQLEvent(event)) { this.logger.warn( 'Received an event that is not compatible with this resolver' ); return; } - try { - if (Array.isArray(event)) { - this.logger.warn('Batch resolvers not implemented yet'); - } else { - return await this.#executeSingleResolver(event); - } + return await this.#executeSingleResolver(event); } catch (error) { this.logger.error( `An error occurred in handler ${event.info.fieldName}`, From 34d8c138640feef623b2c8de4f96080211ed1f04 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 29 May 2025 17:52:40 +0600 Subject: [PATCH 10/49] feat: enhance `RouteHandlerRegistry` to log handler registration and resolution details --- .../appsync-graphql/RouteHandlerRegistry.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 9465078390..4f484e7b62 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -46,16 +46,23 @@ class RouteHandlerRegistry { * Registers a new GraphQL route handler 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 event is received + * @param options.typeName - The name of the GraphQL type to be registered * - * @remarks - * This method logs the registration and stores the handler in the internal resolver map. */ public register(options: RouteHandlerOptions): void { const { fieldName, handler, typeName } = options; this.#logger.debug( - `Registering ${typeName} api handler for field '${fieldName}'` + `Registering ${this.#eventType} route handler for field '${fieldName}' with type '${typeName}'` ); - this.resolvers.set(this.#makeKey(typeName, fieldName), { + const cacheKey = this.#makeKey(typeName, fieldName); + if (this.resolvers.has(cacheKey)) { + this.#logger.warn( + `A route handler for field '${fieldName}' is already registered for '${typeName}'. The previous handler will be replaced.` + ); + } + this.resolvers.set(cacheKey, { fieldName, handler, typeName, @@ -80,6 +87,9 @@ class RouteHandlerRegistry { if (this.#resolverCache.has(cacheKey)) { return this.#resolverCache.get(cacheKey); } + this.#logger.debug( + `Resolving handler '${fieldName}' for type '${typeName}'` + ); const handler = this.resolvers.get(cacheKey); if (handler === undefined) { if (!this.#warningSet.has(cacheKey)) { From 72db338603b9446dcc0b9f9cdb82fa12a4a5e02b Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 29 May 2025 17:53:16 +0600 Subject: [PATCH 11/49] feat: add `onQueryEventFactory` and `onMutationEventFactory` to create event objects for GraphQL operations --- .../event-handler/tests/helpers/factories.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/event-handler/tests/helpers/factories.ts b/packages/event-handler/tests/helpers/factories.ts index a439da81ab..149f08e9eb 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, +}; From 1989c83ec97d206047800e70f2da5ce7b2b0c85d Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 29 May 2025 17:53:33 +0600 Subject: [PATCH 12/49] feat: add unit tests for `AppSyncGraphQLResolver` class to validate event handling and error formatting --- .../AppSyncGraphQLResolver.test.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts 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 0000000000..abfed9eec6 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -0,0 +1,166 @@ +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 resolvers are 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 the event getPost-Query.' + ) + ); + 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 the event addPost-Mutation.' + ) + ); + 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(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, + }); + } + ); +}); From 88723ebd0fb70e5d5e26c46e4a578733cde2dae6 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 29 May 2025 17:53:40 +0600 Subject: [PATCH 13/49] feat: add unit tests for `RouteHandlerRegistry` to validate handler registration and resolution behavior --- .../RouteHandlerRegistry.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts 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 0000000000..0bc956e405 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -0,0 +1,118 @@ +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 handler 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 route handler for field 'getPost' is already registered for 'Query'. The previous handler will be replaced." + ); + }); + + it('will not replace the handler 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, + }); + }); + + it('returns the cached route handler if already evaluated', () => { + // Prepare + const registry = getRegistry(); + registry.register({ + fieldName: 'getPost', + handler: vi.fn(), + typeName: 'Query', + }); + + // Act + registry.resolve('Query', 'getPost'); + registry.resolve('Query', 'getPost'); + + // Assess + expect(console.debug).toHaveBeenCalledTimes(2); // once for registration, once for resolution + expect(console.debug).toHaveBeenLastCalledWith( + "Resolving handler 'getPost' for type 'Query'" + ); + }); +}); From a1a0d280cd40abe79556dc6aedc329042858ddda Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 29 May 2025 17:53:46 +0600 Subject: [PATCH 14/49] feat: add unit tests for `Router` class to validate resolver registration and logging behavior --- .../tests/unit/appsync-graphql/Router.test.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/event-handler/tests/unit/appsync-graphql/Router.test.ts 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 0000000000..c946981158 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Router } from '../../../src/appsync-graphql/index.js'; + +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, + `Registering onQuery route handler for field 'getPost' with type 'Query'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + `Registering onMutation route handler for field 'addPost' with type 'Mutation'` + ); + }); + + 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, + `Registering onQuery route handler for field 'getPost' with type 'Query'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + `Registering onQuery route handler for field 'getAuthor' with type 'Query'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 3, + `Registering onMutation route handler for field 'addPost' with type 'Mutation'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 4, + `Registering onMutation route handler for field 'updatePost' with type 'Mutation'` + ); + + // 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('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; + }); +}); From 376bfaeb8839dc3dbe7cb94fc478fc10b3e6c17f Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 29 May 2025 18:21:51 +0600 Subject: [PATCH 15/49] feat: add test for nested resolvers registration using the decorator pattern in `Router` class --- .../tests/unit/appsync-graphql/Router.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts index c946981158..888b7f2da7 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -86,6 +86,38 @@ describe('Class: Router', () => { 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, + `Registering onQuery route handler for field 'locations' with type 'Query'` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + `Registering onQuery route handler for field 'listLocations' with type 'Query'` + ); + + 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(); From 0681850ede386dd3c77a9acc71c1de556baef551 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 11:37:14 +0600 Subject: [PATCH 16/49] feat: enhance documentation for `resolve` method in `AppSyncGraphQLResolver` class with examples and parameter descriptions --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 503c3fb480..b9c31a4573 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -31,6 +31,59 @@ import { isAppSyncGraphQLEvent } from './utils.js'; * ``` */ 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 resolvers are not implemented yet'); From c763cbcae0a4d8d9e718dc445dddf993fc194013 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 11:38:16 +0600 Subject: [PATCH 17/49] chore: warning message for batch resolver --- .../event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index b9c31a4573..b3b028c827 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -86,7 +86,7 @@ export class AppSyncGraphQLResolver extends Router { */ public async resolve(event: unknown, context: Context): Promise { if (Array.isArray(event)) { - this.logger.warn('Batch resolvers are not implemented yet'); + this.logger.warn('Batch resolver is not implemented yet'); return; } if (!isAppSyncGraphQLEvent(event)) { From 1387c385d51cf5e19fc3c40d22df37160cc575a6 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 11:42:19 +0600 Subject: [PATCH 18/49] fix: return query handler if found --- .../src/appsync-graphql/AppSyncGraphQLResolver.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index b3b028c827..00a959dd39 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -124,14 +124,14 @@ export class AppSyncGraphQLResolver extends Router { typeName, fieldName ); + if (queryHandlerOptions) { + return await queryHandlerOptions.handler.apply(this, [event.arguments]); + } + const mutationHandlerOptions = this.onMutationRegistry.resolve( typeName, fieldName ); - - if (queryHandlerOptions) { - return await queryHandlerOptions.handler.apply(this, [event.arguments]); - } if (mutationHandlerOptions) { return await mutationHandlerOptions.handler.apply(this, [ event.arguments, From 41c29455f8908b9e21a777fe9e98bf1e92f1f557 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 11:46:44 +0600 Subject: [PATCH 19/49] fix: correct warning message for batch resolver in AppSyncGraphQLResolver tests --- .../tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 abfed9eec6..817c703b0f 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -21,7 +21,7 @@ describe('Class: AppSyncGraphQLResolver', () => { // Assess expect(console.warn).toHaveBeenCalledWith( - 'Batch resolvers are not implemented yet' + 'Batch resolver is not implemented yet' ); expect(result).toBeUndefined(); }); From a0354f3ae47e4cce93feaa4bdd3abb5d00236e9d Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 12:01:03 +0600 Subject: [PATCH 20/49] fix: update debug messages to reflect resolver registration format in RouteHandlerRegistry and Router tests --- .../src/appsync-graphql/RouteHandlerRegistry.ts | 4 ++-- .../tests/unit/appsync-graphql/Router.test.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 4f484e7b62..394b7d8985 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -47,14 +47,14 @@ class RouteHandlerRegistry { * * @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 event is received + * @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( - `Registering ${this.#eventType} route handler for field '${fieldName}' with type '${typeName}'` + `Adding resolver ${handler.name} for field ${typeName}.${fieldName}` ); const cacheKey = this.#makeKey(typeName, fieldName); if (this.resolvers.has(cacheKey)) { diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts index 888b7f2da7..e8e3738ce1 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -19,11 +19,11 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - `Registering onQuery route handler for field 'getPost' with type 'Query'` + `Adding resolver ${getPost.name} for field Query.getPost` ); expect(console.debug).toHaveBeenNthCalledWith( 2, - `Registering onMutation route handler for field 'addPost' with type 'Mutation'` + `Adding resolver ${addPost.name} for field Mutation.addPost` ); }); @@ -64,19 +64,19 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - `Registering onQuery route handler for field 'getPost' with type 'Query'` + 'Adding resolver getPost for field Query.getPost' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - `Registering onQuery route handler for field 'getAuthor' with type 'Query'` + 'Adding resolver getAuthor for field Query.getAuthor' ); expect(console.debug).toHaveBeenNthCalledWith( 3, - `Registering onMutation route handler for field 'addPost' with type 'Mutation'` + 'Adding resolver addPost for field Mutation.addPost' ); expect(console.debug).toHaveBeenNthCalledWith( 4, - `Registering onMutation route handler for field 'updatePost' with type 'Mutation'` + 'Adding resolver updatePost for field Mutation.updatePost' ); // verify that class scope is preserved after decorating @@ -106,11 +106,11 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - `Registering onQuery route handler for field 'locations' with type 'Query'` + 'Adding resolver getLocations for field Query.locations' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - `Registering onQuery route handler for field 'listLocations' with type 'Query'` + 'Adding resolver getLocations for field Query.listLocations' ); expect(response).toEqual([ From 59f02db2ae60650c9cd2fbdacb3bb426db289c5f Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 12:33:48 +0600 Subject: [PATCH 21/49] fix: update resolver not found messages for consistency in AppSyncGraphQLResolver and RouteHandlerRegistry --- .../src/appsync-graphql/AppSyncGraphQLResolver.ts | 2 +- .../src/appsync-graphql/RouteHandlerRegistry.ts | 9 +++------ .../unit/appsync-graphql/AppSyncGraphQLResolver.test.ts | 8 ++------ .../unit/appsync-graphql/RouteHandlerRegistry.test.ts | 2 +- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 00a959dd39..86f38ef201 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -139,7 +139,7 @@ export class AppSyncGraphQLResolver extends Router { } throw new ResolverNotFoundException( - `No resolver found for the event ${fieldName}-${typeName}.` + `No resolver found for ${typeName}-${fieldName}` ); } diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 394b7d8985..37bb5d58f4 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -84,18 +84,15 @@ class RouteHandlerRegistry { fieldName: string ): RouteHandlerOptions | undefined { const cacheKey = this.#makeKey(typeName, fieldName); - if (this.#resolverCache.has(cacheKey)) { + if (this.#resolverCache.has(cacheKey)) return this.#resolverCache.get(cacheKey); - } this.#logger.debug( - `Resolving handler '${fieldName}' for type '${typeName}'` + `Looking for resolver for type=${typeName}, field=${fieldName}` ); const handler = this.resolvers.get(cacheKey); if (handler === undefined) { if (!this.#warningSet.has(cacheKey)) { - this.#logger.warn( - `No route handler found for field '${fieldName}' registered for ${this.#eventType}.` - ); + this.#logger.warn(`No resolver found for ${typeName}-${fieldName}`); this.#warningSet.add(cacheKey); } return undefined; 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 817c703b0f..c707a1cbad 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -48,9 +48,7 @@ describe('Class: AppSyncGraphQLResolver', () => { await expect( app.resolve(onQueryEventFactory('getPost'), context) ).rejects.toThrow( - new ResolverNotFoundException( - 'No resolver found for the event getPost-Query.' - ) + new ResolverNotFoundException('No resolver found for Query-getPost') ); expect(console.error).toHaveBeenCalled(); }); @@ -63,9 +61,7 @@ describe('Class: AppSyncGraphQLResolver', () => { await expect( app.resolve(onMutationEventFactory('addPost'), context) ).rejects.toThrow( - new ResolverNotFoundException( - 'No resolver found for the event addPost-Mutation.' - ) + new ResolverNotFoundException('No resolver found for Mutation-addPost') ); expect(console.error).toHaveBeenCalled(); }); diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts index 0bc956e405..3cf40f2e55 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -112,7 +112,7 @@ describe('Class: RouteHandlerRegistry', () => { // Assess expect(console.debug).toHaveBeenCalledTimes(2); // once for registration, once for resolution expect(console.debug).toHaveBeenLastCalledWith( - "Resolving handler 'getPost' for type 'Query'" + 'Looking for resolver for type=Query, field=getPost' ); }); }); From 2c788942d2f91713697784b47d29c4687c232f59 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 12:41:31 +0600 Subject: [PATCH 22/49] fix: doc for Router --- packages/event-handler/src/appsync-graphql/Router.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 548b3d1e53..71e99a9906 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -12,15 +12,15 @@ import type { import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; /** - * Class for registering routes for the `onQuery` and `onMutation` events in AWS AppSync Events APIs. + * Class for registering routes for the `query` and `mutation` events in AWS AppSync GraphQL APIs. */ class Router { /** - * A map of registered routes for the `onQuery` event, keyed by their fieldNames. + * A map of registered routes for the `query` event, keyed by their fieldNames. */ protected readonly onQueryRegistry: RouteHandlerRegistry; /** - * A map of registered routes for the `onMutation` event, keyed by their fieldNames. + * A map of registered routes for the `mutation` event, keyed by their fieldNames. */ protected readonly onMutationRegistry: RouteHandlerRegistry; /** @@ -58,7 +58,7 @@ class Router { } /** - * Register a handler function for the `onQuery` event. + * 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. @@ -142,7 +142,7 @@ class Router { } /** - * Register a handler function for the `onMutation` event. + * 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. From 4a5ef0aecd1837ab2c06479d45113872d39de786 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 13:48:04 +0600 Subject: [PATCH 23/49] refactor: remove unused cache and warning set from `RouteHandlerRegistry` --- .../appsync-graphql/RouteHandlerRegistry.ts | 27 +------------------ .../RouteHandlerRegistry.test.ts | 15 ++++++++--- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 37bb5d58f4..f5449a4971 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -1,4 +1,3 @@ -import { LRUCache } from '@aws-lambda-powertools/commons/utils/lru-cache'; import type { GenericLogger, RouteHandlerOptions, @@ -24,18 +23,6 @@ class RouteHandlerRegistry { * The event type stored in the registry. */ readonly #eventType: 'onQuery' | 'onMutation'; - /** - * A cache for storing the resolved route handlers. - */ - readonly #resolverCache: LRUCache = new LRUCache( - { - maxSize: 100, - } - ); - /** - * A set of warning messages to avoid duplicate warnings. - */ - readonly #warningSet: Set = new Set(); public constructor(options: RouteHandlerRegistryOptions) { this.#logger = options.logger; @@ -83,22 +70,10 @@ class RouteHandlerRegistry { typeName: string, fieldName: string ): RouteHandlerOptions | undefined { - const cacheKey = this.#makeKey(typeName, fieldName); - if (this.#resolverCache.has(cacheKey)) - return this.#resolverCache.get(cacheKey); this.#logger.debug( `Looking for resolver for type=${typeName}, field=${fieldName}` ); - const handler = this.resolvers.get(cacheKey); - if (handler === undefined) { - if (!this.#warningSet.has(cacheKey)) { - this.#logger.warn(`No resolver found for ${typeName}-${fieldName}`); - this.#warningSet.add(cacheKey); - } - return undefined; - } - this.#resolverCache.add(cacheKey, handler); - return handler; + return this.resolvers.get(this.#makeKey(typeName, fieldName)); } /** diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts index 3cf40f2e55..a0fcff6f7e 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -99,9 +99,10 @@ describe('Class: RouteHandlerRegistry', () => { it('returns the cached route handler if already evaluated', () => { // Prepare const registry = getRegistry(); + const handler = vi.fn(); registry.register({ fieldName: 'getPost', - handler: vi.fn(), + handler, typeName: 'Query', }); @@ -110,8 +111,16 @@ describe('Class: RouteHandlerRegistry', () => { registry.resolve('Query', 'getPost'); // Assess - expect(console.debug).toHaveBeenCalledTimes(2); // once for registration, once for resolution - expect(console.debug).toHaveBeenLastCalledWith( + expect(console.debug).toHaveBeenNthCalledWith( + 1, + `Adding resolver ${handler.name} for field Query.getPost` + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Looking for resolver for type=Query, field=getPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 3, 'Looking for resolver for type=Query, field=getPost' ); }); From 2d9ec9acb33502ab5e4d884a34fd9f37e1c95f12 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 13:52:27 +0600 Subject: [PATCH 24/49] fix: update documentation for resolve method in RouteHandlerRegistry --- .../src/appsync-graphql/RouteHandlerRegistry.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index f5449a4971..53e94354cb 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -59,12 +59,8 @@ class RouteHandlerRegistry { /** * Resolves the handler for a specific GraphQL API event. * - * This method first checks an internal cache for the handler. If not found, it attempts to retrieve - * the handler from the registered resolvers. If the handler is still not found, a warning is logged - * (only once per missing handler), and `undefined` is returned. - * - * @param typeName - The name of the GraphQL type. - * @param fieldName - The name of the field within the GraphQL type. + * @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, From ff97740b24ae54e9f59699cce9ef8087e13c1991 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 13:53:38 +0600 Subject: [PATCH 25/49] refactor: remove redundant test for cached route handler evaluation in RouteHandlerRegistry --- .../RouteHandlerRegistry.test.ts | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts index a0fcff6f7e..134f90cdd4 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -95,33 +95,4 @@ describe('Class: RouteHandlerRegistry', () => { handler: otherHandler, }); }); - - it('returns the cached route handler if already evaluated', () => { - // Prepare - const registry = getRegistry(); - const handler = vi.fn(); - registry.register({ - fieldName: 'getPost', - handler, - typeName: 'Query', - }); - - // Act - registry.resolve('Query', 'getPost'); - registry.resolve('Query', 'getPost'); - - // Assess - expect(console.debug).toHaveBeenNthCalledWith( - 1, - `Adding resolver ${handler.name} for field Query.getPost` - ); - expect(console.debug).toHaveBeenNthCalledWith( - 2, - 'Looking for resolver for type=Query, field=getPost' - ); - expect(console.debug).toHaveBeenNthCalledWith( - 3, - 'Looking for resolver for type=Query, field=getPost' - ); - }); }); From 86ea83fd7d86ab5f9d1b97b3a20691ba34502fc2 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 13:58:27 +0600 Subject: [PATCH 26/49] fix: update import path for Router in Router.test.ts --- .../event-handler/tests/unit/appsync-graphql/Router.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts index e8e3738ce1..e9db3b2e44 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -1,5 +1,5 @@ +import { Router } from 'src/appsync-graphql/Router.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Router } from '../../../src/appsync-graphql/index.js'; describe('Class: Router', () => { beforeEach(() => { From b7c4a9bfd691df53bb4541f0ded449ab1b1b5839 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 14:10:01 +0600 Subject: [PATCH 27/49] fix: update debug messages to include event type in RouteHandlerRegistry and Router --- .../src/appsync-graphql/RouteHandlerRegistry.ts | 4 ++-- .../AppSyncGraphQLResolver.test.ts | 12 ++++++++++++ .../tests/unit/appsync-graphql/Router.test.ts | 16 ++++++++-------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 53e94354cb..f47a6627e8 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -41,7 +41,7 @@ class RouteHandlerRegistry { public register(options: RouteHandlerOptions): void { const { fieldName, handler, typeName } = options; this.#logger.debug( - `Adding resolver ${handler.name} for field ${typeName}.${fieldName}` + `Adding ${this.#eventType} resolver for field ${typeName}.${fieldName}` ); const cacheKey = this.#makeKey(typeName, fieldName); if (this.resolvers.has(cacheKey)) { @@ -67,7 +67,7 @@ class RouteHandlerRegistry { fieldName: string ): RouteHandlerOptions | undefined { this.#logger.debug( - `Looking for resolver for type=${typeName}, field=${fieldName}` + `Looking for ${this.#eventType} resolver for type=${typeName}, field=${fieldName}` ); return this.resolvers.get(this.#makeKey(typeName, fieldName)); } 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 c707a1cbad..cd781224ee 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -112,6 +112,18 @@ describe('Class: AppSyncGraphQLResolver', () => { ); // 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', diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts index e9db3b2e44..86878bebfe 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -19,11 +19,11 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - `Adding resolver ${getPost.name} for field Query.getPost` + 'Adding onQuery resolver for field Query.getPost' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - `Adding resolver ${addPost.name} for field Mutation.addPost` + 'Adding onMutation resolver for field Mutation.addPost' ); }); @@ -64,19 +64,19 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - 'Adding resolver getPost for field Query.getPost' + 'Adding onQuery resolver for field Query.getPost' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - 'Adding resolver getAuthor for field Query.getAuthor' + 'Adding onQuery resolver for field Query.getAuthor' ); expect(console.debug).toHaveBeenNthCalledWith( 3, - 'Adding resolver addPost for field Mutation.addPost' + 'Adding onMutation resolver for field Mutation.addPost' ); expect(console.debug).toHaveBeenNthCalledWith( 4, - 'Adding resolver updatePost for field Mutation.updatePost' + 'Adding onMutation resolver for field Mutation.updatePost' ); // verify that class scope is preserved after decorating @@ -106,11 +106,11 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - 'Adding resolver getLocations for field Query.locations' + 'Adding onQuery resolver for field Query.locations' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - 'Adding resolver getLocations for field Query.listLocations' + 'Adding onQuery resolver for field Query.listLocations' ); expect(response).toEqual([ From 6587a7e796faf584935ad9686bd1c22a2f2ca6b8 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 1 Jun 2025 14:19:39 +0600 Subject: [PATCH 28/49] fix: update terminology from "handler" to "resolver" in RouteHandlerRegistry and related tests --- .../src/appsync-graphql/RouteHandlerRegistry.ts | 4 ++-- .../tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index f47a6627e8..78dcf92640 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -30,7 +30,7 @@ class RouteHandlerRegistry { } /** - * Registers a new GraphQL route handler for a specific type and field. + * 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 @@ -46,7 +46,7 @@ class RouteHandlerRegistry { const cacheKey = this.#makeKey(typeName, fieldName); if (this.resolvers.has(cacheKey)) { this.#logger.warn( - `A route handler for field '${fieldName}' is already registered for '${typeName}'. The previous handler will be replaced.` + `A resolver for field '${fieldName}' is already registered for '${typeName}'. The previous resolver will be replaced.` ); } this.resolvers.set(cacheKey, { diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts index 134f90cdd4..b333b91d4b 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -34,7 +34,7 @@ describe('Class: RouteHandlerRegistry', () => { } ); - it('logs a warning and replaces the previous handler if the field & type is already registered', () => { + it('logs a warning and replaces the previous resolver if the field & type is already registered', () => { // Prepare const registry = getRegistry(); const originalHandler = vi.fn(); @@ -60,11 +60,11 @@ describe('Class: RouteHandlerRegistry', () => { handler: otherHandler, }); expect(console.warn).toHaveBeenCalledWith( - "A route handler for field 'getPost' is already registered for 'Query'. The previous handler will be replaced." + "A resolver for field 'getPost' is already registered for 'Query'. The previous resolver will be replaced." ); }); - it('will not replace the handler if the event type is different', () => { + it('will not replace the resolver if the event type is different', () => { // Prepare const registry = getRegistry(); const originalHandler = vi.fn(); From decddbea6a76924868891492e29845ccab48173f Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 5 Jun 2025 12:04:16 +0600 Subject: [PATCH 29/49] fix: refactor logger initialization and import structure in Router and related types --- .../event-handler/src/appsync-graphql/Router.ts | 8 ++++++-- .../event-handler/src/types/appsync-graphql.ts | 15 +-------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 71e99a9906..8bf30b5d66 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -2,8 +2,9 @@ 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 { - GenericLogger, GraphQlRouteOptions, GraphQlRouterOptions, OnMutationHandler, @@ -40,7 +41,10 @@ class Router { public constructor(options?: GraphQlRouterOptions) { this.envService = new EnvironmentVariablesService(); - const alcLogLevel = this.envService.get('AWS_LAMBDA_LOG_LEVEL'); + const alcLogLevel = getStringFromEnv({ + key: 'AWS_LAMBDA_LOG_LEVEL', + defaultValue: '', + }); this.logger = options?.logger ?? { debug: alcLogLevel === 'DEBUG' ? console.debug : () => undefined, error: console.error, diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 331d5d4245..ad9b81d429 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,18 +1,5 @@ import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; - -// biome-ignore lint/suspicious/noExplicitAny: We intentionally use `any` here to represent any type of data and keep the logger is as flexible as possible. -type Anything = any; - -/** - * Interface for a generic logger object. - */ -type GenericLogger = { - trace?: (...content: Anything[]) => void; - debug: (...content: Anything[]) => void; - info?: (...content: Anything[]) => void; - warn: (...content: Anything[]) => void; - error: (...content: Anything[]) => void; -}; +import type { Anything, GenericLogger } from './common.js'; // #region OnQuery fn From cc94ba21ac403c2f06a6dd86ae8a2256ba38bf50 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 18 Jun 2025 12:50:44 +0600 Subject: [PATCH 30/49] fix: enhance type safety for onQuery and onMutation handlers with generic parameters --- .../src/appsync-graphql/Router.ts | 58 +++++++++++++++---- .../src/types/appsync-graphql.ts | 30 +++++++--- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 8bf30b5d66..69a2ffe176 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -78,6 +78,23 @@ class Router { * return payload; * }); + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * + * You can also specify the type of the arguments using a generic type parameter: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onQuery<{ postId: string }>('getPost', async ({ postId }) => { + * // postId is now typed as string + * return { id: postId }; + * }); + * export const handler = async (event, context) => * app.resolve(event, context); * ``` @@ -111,24 +128,24 @@ class Router { * @param options - Optional route options. * @param options.typeName - The name of the GraphQL type to use for the resolver (defaults to 'Query'). */ - public onQuery( + public onQuery>( fieldName: string, - handler: OnQueryHandler, + handler: OnQueryHandler, options?: GraphQlRouteOptions ): void; public onQuery( fieldName: string, options?: GraphQlRouteOptions ): MethodDecorator; - public onQuery( + public onQuery>( fieldName: string, - handler?: OnQueryHandler | GraphQlRouteOptions, + handler?: OnQueryHandler | GraphQlRouteOptions, options?: GraphQlRouteOptions ): MethodDecorator | undefined { if (handler && typeof handler === 'function') { this.onQueryRegistry.register({ fieldName, - handler, + handler: handler as OnQueryHandler>, typeName: options?.typeName ?? 'Query', }); return; @@ -138,7 +155,7 @@ class Router { const routeOptions = isRecord(handler) ? handler : options; this.onQueryRegistry.register({ fieldName, - handler: descriptor.value, + handler: descriptor.value as OnQueryHandler>, typeName: routeOptions?.typeName ?? 'Query', }); return descriptor; @@ -166,6 +183,23 @@ class Router { * app.resolve(event, context); * ``` * + * You can also specify the type of the arguments using a generic type parameter: + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.onMutation<{ title: string; content: string }>('createPost', async ({ title, content }) => { + * // title and content are now typed as string + * return { id: '123', title, content }; + * }); + * + * export const handler = async (event, context) => + * app.resolve(event, context); + * ``` + * * As a decorator: * * @example @@ -195,24 +229,24 @@ class Router { * @param options - Optional route options. * @param options.typeName - The name of the GraphQL type to use for the resolver (defaults to 'Mutation'). */ - public onMutation( + public onMutation>( fieldName: string, - handler: OnMutationHandler, + handler: OnMutationHandler, options?: GraphQlRouteOptions ): void; public onMutation( fieldName: string, options?: GraphQlRouteOptions ): MethodDecorator; - public onMutation( + public onMutation>( fieldName: string, - handler?: OnMutationHandler | GraphQlRouteOptions, + handler?: OnMutationHandler | GraphQlRouteOptions, options?: GraphQlRouteOptions ): MethodDecorator | undefined { if (handler && typeof handler === 'function') { this.onMutationRegistry.register({ fieldName, - handler, + handler: handler as OnMutationHandler>, typeName: options?.typeName ?? 'Mutation', }); return; @@ -222,7 +256,7 @@ class Router { const routeOptions = isRecord(handler) ? handler : options; this.onMutationRegistry.register({ fieldName, - handler: descriptor.value, + handler: descriptor.value as OnMutationHandler>, typeName: routeOptions?.typeName ?? 'Mutation', }); return descriptor; diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index ad9b81d429..fc4f4f485a 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,21 +1,33 @@ import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; -import type { Anything, GenericLogger } from './common.js'; +import type { GenericLogger } from './common.js'; // #region OnQuery fn -type OnQuerySyncHandlerFn = ({ ...args }: Anything) => unknown; +type OnQuerySyncHandlerFn> = ( + args: TParams +) => unknown; -type OnQueryHandlerFn = ({ ...args }: Anything) => Promise; +type OnQueryHandlerFn> = ( + args: TParams +) => Promise; -type OnQueryHandler = OnQuerySyncHandlerFn | OnQueryHandlerFn; +type OnQueryHandler> = + | OnQuerySyncHandlerFn + | OnQueryHandlerFn; // #region OnMutation fn -type OnMutationSyncHandlerFn = ({ ...args }: Anything) => unknown; +type OnMutationSyncHandlerFn> = ( + args: TParams +) => unknown; -type OnMutationHandlerFn = ({ ...args }: Anything) => Promise; +type OnMutationHandlerFn> = ( + args: TParams +) => Promise; -type OnMutationHandler = OnMutationSyncHandlerFn | OnMutationHandlerFn; +type OnMutationHandler> = + | OnMutationSyncHandlerFn + | OnMutationHandlerFn; // #region Resolver registry @@ -47,7 +59,9 @@ type RouteHandlerOptions = { /** * The handler function to be called when the event is received */ - handler: OnQueryHandler | OnMutationHandler; + handler: + | OnQueryHandler> + | OnMutationHandler>; /** * The field name of the event to be registered */ From 4df541ac672f57a45da6590afda724583d4bfa26 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 18 Jun 2025 17:02:09 +0600 Subject: [PATCH 31/49] refactor: unify resolver registration by replacing onQuery and onMutation with a single resolver method --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 20 +- .../appsync-graphql/RouteHandlerRegistry.ts | 13 +- .../src/appsync-graphql/Router.ts | 204 +++++------------- .../src/types/appsync-graphql.ts | 42 ++-- 4 files changed, 74 insertions(+), 205 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 86f38ef201..4229f00415 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -108,32 +108,24 @@ export class AppSyncGraphQLResolver extends Router { } /** - * Executes the appropriate resolver (query or mutation) for a given AppSync GraphQL event. + * Executes the appropriate resolver 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`. + * from the resolver registry. 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( + const resolverHandlerOptions = this.resolverRegistry.resolve( typeName, fieldName ); - if (mutationHandlerOptions) { - return await mutationHandlerOptions.handler.apply(this, [ + if (resolverHandlerOptions) { + return await resolverHandlerOptions.handler.apply(this, [ event.arguments, ]); } diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts index 78dcf92640..fde0969b66 100644 --- a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -5,7 +5,7 @@ import type { } from '../types/appsync-graphql.js'; /** - * Registry for storing route handlers for the `query` and `mutation` events in AWS AppSync GraphQL API's. + * Registry for storing route handlers for GraphQL resolvers 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. @@ -19,14 +19,9 @@ class RouteHandlerRegistry { * 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'; } /** @@ -40,9 +35,7 @@ class RouteHandlerRegistry { */ public register(options: RouteHandlerOptions): void { const { fieldName, handler, typeName } = options; - this.#logger.debug( - `Adding ${this.#eventType} resolver for field ${typeName}.${fieldName}` - ); + this.#logger.debug(`Adding resolver for field ${typeName}.${fieldName}`); const cacheKey = this.#makeKey(typeName, fieldName); if (this.resolvers.has(cacheKey)) { this.#logger.warn( @@ -67,7 +60,7 @@ class RouteHandlerRegistry { fieldName: string ): RouteHandlerOptions | undefined { this.#logger.debug( - `Looking for ${this.#eventType} resolver for type=${typeName}, field=${fieldName}` + `Looking for resolver for type=${typeName}, field=${fieldName}` ); return this.resolvers.get(this.#makeKey(typeName, fieldName)); } diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 69a2ffe176..fd3f4c7215 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -1,29 +1,21 @@ -import { - EnvironmentVariablesService, - isRecord, -} from '@aws-lambda-powertools/commons'; +import { EnvironmentVariablesService } 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, + ResolverHandler, } 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 for registering resolvers for GraphQL events in AWS AppSync GraphQL APIs. */ class Router { /** - * A map of registered routes for the `query` event, keyed by their fieldNames. + * A map of registered routes for all GraphQL events, 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; + protected readonly resolverRegistry: RouteHandlerRegistry; /** * A logger instance to be used for logging debug, warning, and error messages. * @@ -50,133 +42,39 @@ class Router { error: console.error, warn: console.warn, }; - this.onQueryRegistry = new RouteHandlerRegistry({ - logger: this.logger, - eventType: 'onQuery', - }); - this.onMutationRegistry = new RouteHandlerRegistry({ + this.resolverRegistry = 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'; + * Register a resolver function for any GraphQL event. * - * const app = new AppSyncGraphQLResolver(); + * Registers a handler for a specific GraphQL field. The handler will be invoked when a request is made + * for the specified field. * - * app.onQuery('getPost', async (payload) => { - * // your business logic here - * return payload; - * }); - - * export const handler = async (event, context) => - * app.resolve(event, context); - * ``` - * - * You can also specify the type of the arguments using a generic type parameter: - * * @example * ```ts * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; * * const app = new AppSyncGraphQLResolver(); * - * app.onQuery<{ postId: string }>('getPost', async ({ postId }) => { - * // postId is now typed as string - * return { id: postId }; + * // Register a Query resolver + * app.resolver(async (payload) => { + * // your business logic here + * return payload; + * }, { + * fieldName: 'getPost' * }); - - * 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: handler as OnQueryHandler>, - typeName: options?.typeName ?? 'Query', - }); - return; - } - - return (_target, _propertyKey, descriptor: PropertyDescriptor) => { - const routeOptions = isRecord(handler) ? handler : options; - this.onQueryRegistry.register({ - fieldName, - handler: descriptor.value as OnQueryHandler>, - 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) => { + * // Register a Mutation resolver + * app.resolver(async (payload) => { * // your business logic here * return payload; + * }, { + * fieldName: 'createPost', + * typeName: 'Mutation' * }); * * export const handler = async (event, context) => @@ -191,9 +89,11 @@ class Router { * * const app = new AppSyncGraphQLResolver(); * - * app.onMutation<{ title: string; content: string }>('createPost', async ({ title, content }) => { - * // title and content are now typed as string - * return { id: '123', title, content }; + * app.resolver<{ postId: string }>(async ({ postId }) => { + * // postId is now typed as string + * return { id: postId }; + * }, { + * fieldName: 'getPost' * }); * * export const handler = async (event, context) => @@ -209,8 +109,8 @@ class Router { * const app = new AppSyncGraphQLResolver(); * * class Lambda { - * @app.onMutation('createPost') - * async handleCreatePost(payload) { + * @app.resolver({ fieldName: 'getPost' }) + * async handleGetPost(payload) { * // your business logic here * return payload; * } @@ -224,41 +124,43 @@ class Router { * 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'). + * @param options - Route options including the required fieldName and optional typeName. + * @param options.fieldName - The name of the field to register the handler for. + * @param options.typeName - The name of the GraphQL type to use for the resolver (defaults to 'Query'). */ - public onMutation>( - fieldName: string, - handler: OnMutationHandler, - options?: GraphQlRouteOptions + public resolver>( + handler: ResolverHandler, + options: GraphQlRouteOptions ): void; - public onMutation( - fieldName: string, - options?: GraphQlRouteOptions - ): MethodDecorator; - public onMutation>( - fieldName: string, - handler?: OnMutationHandler | GraphQlRouteOptions, + public resolver(options: GraphQlRouteOptions): MethodDecorator; + public resolver>( + handler: ResolverHandler | GraphQlRouteOptions, options?: GraphQlRouteOptions ): MethodDecorator | undefined { - if (handler && typeof handler === 'function') { - this.onMutationRegistry.register({ - fieldName, - handler: handler as OnMutationHandler>, - typeName: options?.typeName ?? 'Mutation', + if (typeof handler === 'function') { + const resolverOptions = options as GraphQlRouteOptions; + const typeName = resolverOptions.typeName ?? 'Query'; + + this.resolverRegistry.register({ + fieldName: resolverOptions.fieldName, + handler: handler as ResolverHandler>, + typeName, }); + return; } + const resolverOptions = handler; return (_target, _propertyKey, descriptor: PropertyDescriptor) => { - const routeOptions = isRecord(handler) ? handler : options; - this.onMutationRegistry.register({ - fieldName, - handler: descriptor.value as OnMutationHandler>, - typeName: routeOptions?.typeName ?? 'Mutation', + const typeName = resolverOptions.typeName ?? 'Query'; + + this.resolverRegistry.register({ + fieldName: resolverOptions.fieldName, + handler: descriptor.value, + typeName, }); + return descriptor; }; } diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index fc4f4f485a..b545732633 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,33 +1,19 @@ import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; import type { GenericLogger } from './common.js'; -// #region OnQuery fn +// #region Resolver fn -type OnQuerySyncHandlerFn> = ( +type ResolverSyncHandlerFn> = ( args: TParams ) => unknown; -type OnQueryHandlerFn> = ( +type ResolverHandlerFn> = ( args: TParams ) => Promise; -type OnQueryHandler> = - | OnQuerySyncHandlerFn - | OnQueryHandlerFn; - -// #region OnMutation fn - -type OnMutationSyncHandlerFn> = ( - args: TParams -) => unknown; - -type OnMutationHandlerFn> = ( - args: TParams -) => Promise; - -type OnMutationHandler> = - | OnMutationSyncHandlerFn - | OnMutationHandlerFn; +type ResolverHandler> = + | ResolverSyncHandlerFn + | ResolverHandlerFn; // #region Resolver registry @@ -41,11 +27,6 @@ type RouteHandlerRegistryOptions = { * 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'; }; /** @@ -59,9 +40,7 @@ type RouteHandlerOptions = { /** * The handler function to be called when the event is received */ - handler: - | OnQueryHandler> - | OnMutationHandler>; + handler: ResolverHandler>; /** * The field name of the event to be registered */ @@ -90,6 +69,10 @@ type GraphQlRouterOptions = { * Options for registering a route */ type GraphQlRouteOptions = { + /** + * The name of the field to be registered + */ + fieldName: string; /** * The type name of the event to be registered */ @@ -135,6 +118,5 @@ export type { GraphQlRouterOptions, GraphQlRouteOptions, AppSyncGraphQLEvent, - OnQueryHandler, - OnMutationHandler, + ResolverHandler, }; From a1dbfb8d7c6e1d308a4998a81e3ec027c64d1a31 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 18 Jun 2025 17:02:18 +0600 Subject: [PATCH 32/49] test: replace onQuery and onMutation methods with a unified resolver method in tests --- .../AppSyncGraphQLResolver.test.ts | 59 +++++++++++-------- .../tests/unit/appsync-graphql/Router.test.ts | 36 +++++------ 2 files changed, 54 insertions(+), 41 deletions(-) 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 cd781224ee..36f787e24d 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -69,13 +69,18 @@ describe('Class: AppSyncGraphQLResolver', () => { 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', - }; - }); + app.resolver<{ id: string }>( + async ({ id }) => { + return { + id, + title: 'Post Title', + content: 'Post Content', + }; + }, + { + fieldName: 'getPost', + } + ); // Act const result = await app.resolve( @@ -94,13 +99,19 @@ describe('Class: AppSyncGraphQLResolver', () => { 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, - }; - }); + app.resolver<{ title: string; content: string }>( + async ({ title, content }) => { + return { + id: '123', + title, + content, + }; + }, + { + fieldName: 'addPost', + typeName: 'Mutation', + } + ); // Act const result = await app.resolve( @@ -114,15 +125,11 @@ describe('Class: AppSyncGraphQLResolver', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - 'Adding onMutation resolver for field Mutation.addPost' + 'Adding 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' + 'Looking for resolver for type=Mutation, field=addPost' ); expect(result).toEqual({ id: '123', @@ -152,9 +159,15 @@ describe('Class: AppSyncGraphQLResolver', () => { async ({ error, message }) => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); - app.onMutation('addPost', async () => { - throw error; - }); + app.resolver( + async () => { + throw error; + }, + { + fieldName: 'addPost', + typeName: 'Mutation', + } + ); // Act const result = await app.resolve( diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts index 86878bebfe..b515e299b6 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -13,17 +13,17 @@ describe('Class: Router', () => { const addPost = vi.fn(async () => true); // Act - router.onQuery('getPost', getPost, { typeName: 'Query' }); - router.onMutation('addPost', addPost, { typeName: 'Mutation' }); + router.resolver(getPost, { typeName: 'Query', fieldName: 'getPost' }); + router.resolver(addPost, { typeName: 'Mutation', fieldName: 'addPost' }); // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - 'Adding onQuery resolver for field Query.getPost' + 'Adding resolver for field Query.getPost' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - 'Adding onMutation resolver for field Mutation.addPost' + 'Adding resolver for field Mutation.addPost' ); }); @@ -35,22 +35,22 @@ describe('Class: Router', () => { class Lambda { readonly prop = 'value'; - @router.onQuery('getPost') + @router.resolver({ fieldName: 'getPost' }) public getPost() { return `${this.prop} foo`; } - @router.onQuery('getAuthor', { typeName: 'Query' }) + @router.resolver({ fieldName: 'getAuthor', typeName: 'Query' }) public getAuthor() { return `${this.prop} bar`; } - @router.onMutation('addPost') + @router.resolver({ fieldName: 'addPost', typeName: 'Mutation' }) public addPost() { return `${this.prop} bar`; } - @router.onMutation('updatePost', { typeName: 'Mutation' }) + @router.resolver({ fieldName: 'updatePost', typeName: 'Mutation' }) public updatePost() { return `${this.prop} baz`; } @@ -64,19 +64,19 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - 'Adding onQuery resolver for field Query.getPost' + 'Adding resolver for field Query.getPost' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - 'Adding onQuery resolver for field Query.getAuthor' + 'Adding resolver for field Query.getAuthor' ); expect(console.debug).toHaveBeenNthCalledWith( 3, - 'Adding onMutation resolver for field Mutation.addPost' + 'Adding resolver for field Mutation.addPost' ); expect(console.debug).toHaveBeenNthCalledWith( 4, - 'Adding onMutation resolver for field Mutation.updatePost' + 'Adding resolver for field Mutation.updatePost' ); // verify that class scope is preserved after decorating @@ -94,8 +94,8 @@ describe('Class: Router', () => { class Lambda { readonly prop = 'value'; - @router.onQuery('listLocations') - @router.onQuery('locations') + @router.resolver({ fieldName: 'listLocations' }) + @router.resolver({ fieldName: 'locations' }) public getLocations() { return [{ name: 'Location 1', description: 'Description 1' }]; } @@ -106,11 +106,11 @@ describe('Class: Router', () => { // Assess expect(console.debug).toHaveBeenNthCalledWith( 1, - 'Adding onQuery resolver for field Query.locations' + 'Adding resolver for field Query.locations' ); expect(console.debug).toHaveBeenNthCalledWith( 2, - 'Adding onQuery resolver for field Query.listLocations' + 'Adding resolver for field Query.listLocations' ); expect(response).toEqual([ @@ -123,7 +123,7 @@ describe('Class: Router', () => { const router = new Router(); // Act - router.onQuery('getPost', vi.fn()); + router.resolver(vi.fn(), { fieldName: 'getPost' }); // Assess expect(console.debug).not.toHaveBeenCalled(); @@ -135,7 +135,7 @@ describe('Class: Router', () => { const router = new Router(); // Act - router.onQuery('getPost', vi.fn()); + router.resolver(vi.fn(), { fieldName: 'getPost' }); // Assess expect(console.debug).toHaveBeenCalled(); From 4f927d8141d47e090b1c12dc376fb542e8931964 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Wed, 18 Jun 2025 18:21:42 +0600 Subject: [PATCH 33/49] fix: enhance type parameters for resolver handlers and route options --- packages/event-handler/src/appsync-graphql/Router.ts | 10 +++++----- packages/event-handler/src/types/appsync-graphql.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index fd3f4c7215..314c38359f 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -140,11 +140,11 @@ class Router { ): MethodDecorator | undefined { if (typeof handler === 'function') { const resolverOptions = options as GraphQlRouteOptions; - const typeName = resolverOptions.typeName ?? 'Query'; + const { typeName = 'Query', fieldName } = resolverOptions; this.resolverRegistry.register({ - fieldName: resolverOptions.fieldName, - handler: handler as ResolverHandler>, + fieldName, + handler: handler as ResolverHandler, typeName, }); @@ -153,10 +153,10 @@ class Router { const resolverOptions = handler; return (_target, _propertyKey, descriptor: PropertyDescriptor) => { - const typeName = resolverOptions.typeName ?? 'Query'; + const { typeName = 'Query', fieldName } = resolverOptions; this.resolverRegistry.register({ - fieldName: resolverOptions.fieldName, + fieldName, handler: descriptor.value, typeName, }); diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index b545732633..64b765c296 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -3,15 +3,15 @@ import type { GenericLogger } from './common.js'; // #region Resolver fn -type ResolverSyncHandlerFn> = ( +type ResolverSyncHandlerFn> = ( args: TParams ) => unknown; -type ResolverHandlerFn> = ( +type ResolverHandlerFn> = ( args: TParams ) => Promise; -type ResolverHandler> = +type ResolverHandler> = | ResolverSyncHandlerFn | ResolverHandlerFn; @@ -36,11 +36,11 @@ type RouteHandlerRegistryOptions = { * @property fieldName - The name of the field to be registered * @property typeName - The name of the type to be registered */ -type RouteHandlerOptions = { +type RouteHandlerOptions> = { /** * The handler function to be called when the event is received */ - handler: ResolverHandler>; + handler: ResolverHandler; /** * The field name of the event to be registered */ From 33ab42c25306b3016b261cd5b5e62057fdca8032 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 18:30:03 +0600 Subject: [PATCH 34/49] refactor: resolve PR feedbacks and replace onQuery and onMutation event factories with a unified onGraphqlEventFactory in tests --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 4 +- .../AppSyncGraphQLResolver.test.ts | 92 ++++++++++++++++--- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 4229f00415..94e6adb7e0 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -125,9 +125,7 @@ export class AppSyncGraphQLResolver extends Router { fieldName ); if (resolverHandlerOptions) { - return await resolverHandlerOptions.handler.apply(this, [ - event.arguments, - ]); + return resolverHandlerOptions.handler.apply(this, [event.arguments]); } throw new ResolverNotFoundException( 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 36f787e24d..e9419349bf 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -1,8 +1,5 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import { - onMutationEventFactory, - onQueryEventFactory, -} from 'tests/helpers/factories.js'; +import { onGraphqlEventFactory } 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'; @@ -17,7 +14,10 @@ describe('Class: AppSyncGraphQLResolver', () => { const app = new AppSyncGraphQLResolver({ logger: console }); // Act - const result = await app.resolve([onQueryEventFactory()], context); + const result = await app.resolve( + [onGraphqlEventFactory('getPost', 'Query')], + context + ); // Assess expect(console.warn).toHaveBeenCalledWith( @@ -40,26 +40,26 @@ describe('Class: AppSyncGraphQLResolver', () => { expect(result).toBeUndefined(); }); - it('throw error if there are no onQuery handlers', async () => { + it('throws error if there are no onQuery handlers', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); // Act && Assess await expect( - app.resolve(onQueryEventFactory('getPost'), context) + app.resolve(onGraphqlEventFactory('getPost', 'Query'), 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 () => { + it('throws error if there are no onMutation handlers', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); // Act && Assess await expect( - app.resolve(onMutationEventFactory('addPost'), context) + app.resolve(onGraphqlEventFactory('addPost', 'Mutation'), context) ).rejects.toThrow( new ResolverNotFoundException('No resolver found for Mutation-addPost') ); @@ -84,7 +84,7 @@ describe('Class: AppSyncGraphQLResolver', () => { // Act const result = await app.resolve( - onQueryEventFactory('getPost', { id: '123' }), + onGraphqlEventFactory('getPost', 'Query', { id: '123' }), context ); @@ -115,7 +115,7 @@ describe('Class: AppSyncGraphQLResolver', () => { // Act const result = await app.resolve( - onMutationEventFactory('addPost', { + onGraphqlEventFactory('addPost', 'Mutation', { title: 'Post Title', content: 'Post Content', }), @@ -138,6 +138,74 @@ describe('Class: AppSyncGraphQLResolver', () => { }); }); + it('logs only warnings and errors using global console object if no logger supplied', async () => { + // Prepare + const app = new AppSyncGraphQLResolver(); + app.resolver<{ title: string; content: string }>( + async ({ title, content }) => { + return { + id: '123', + title, + content, + }; + }, + { + fieldName: 'addPost', + typeName: 'Mutation', + } + ); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('addPost', 'Mutation', { + title: 'Post Title', + content: 'Post Content', + }), + context + ); + + // Assess + expect(console.debug).not.toHaveBeenCalledWith(); + expect(console.debug).not.toHaveBeenCalledWith(); + expect(result).toEqual({ + id: '123', + title: 'Post Title', + content: 'Post Content', + }); + }); + + it('emits debug message when AWS_LAMBDA_LOG_LEVEL is set to DEBUG', async () => { + // Prepare + vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); + const app = new AppSyncGraphQLResolver(); + + app.resolver<{ title: string; content: string }>( + async ({ title, content }) => { + return { + id: '123', + title, + content, + }; + }, + { + fieldName: 'addPost', + typeName: 'Mutation', + } + ); + + // Act + await app.resolve( + onGraphqlEventFactory('addPost', 'Mutation', { + title: 'Post Title', + content: 'Post Content', + }), + context + ); + + // Assess + expect(console.debug).toHaveBeenCalled(); + }); + it.each([ { type: 'base error', @@ -171,7 +239,7 @@ describe('Class: AppSyncGraphQLResolver', () => { // Act const result = await app.resolve( - onMutationEventFactory('addPost', { + onGraphqlEventFactory('addPost', 'Mutation', { title: 'Post Title', content: 'Post Content', }), From 0802bbdbdb1c112c6e01538d0eecf1cf7c132160 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 18:40:13 +0600 Subject: [PATCH 35/49] refactor: consolidate onQuery and onMutation event factories into a single onGraphqlEventFactory --- .../event-handler/tests/helpers/factories.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/event-handler/tests/helpers/factories.ts b/packages/event-handler/tests/helpers/factories.ts index 149f08e9eb..8f3d8fd328 100644 --- a/packages/event-handler/tests/helpers/factories.ts +++ b/packages/event-handler/tests/helpers/factories.ts @@ -98,21 +98,14 @@ const createEventFactory = ( stash: {}, }); -const onQueryEventFactory = ( - fieldName = 'getPost', - args = {}, - typeName = 'Query' -) => createEventFactory(fieldName, args, typeName); - -const onMutationEventFactory = ( - fieldName = 'addPost', - args = {}, - typeName = 'Mutation' +const onGraphqlEventFactory = ( + fieldName: string, + typeName: 'Query' | 'Mutation', + args: Record = {} ) => createEventFactory(fieldName, args, typeName); export { onPublishEventFactory, onSubscribeEventFactory, - onQueryEventFactory, - onMutationEventFactory, + onGraphqlEventFactory, }; From 3996555275174e733e37603b3dc983efac92b146 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 19:29:51 +0600 Subject: [PATCH 36/49] feat: add scalar types utility functions and corresponding tests --- .../src/appsync-graphql/scalarTypesUtils.ts | 89 +++++++++++ .../appsync-graphql/scalarTypesUtils.test.ts | 143 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 packages/event-handler/src/appsync-graphql/scalarTypesUtils.ts create mode 100644 packages/event-handler/tests/unit/appsync-graphql/scalarTypesUtils.test.ts diff --git a/packages/event-handler/src/appsync-graphql/scalarTypesUtils.ts b/packages/event-handler/src/appsync-graphql/scalarTypesUtils.ts new file mode 100644 index 0000000000..1b7cce0a1b --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/scalarTypesUtils.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'node:crypto'; + +/** + * ID - A unique identifier for an object. This scalar is serialized like a String + * but isn't meant to be human-readable. + */ +export const makeId = () => randomUUID(); + +/** + * AWSTimestamp - An integer value representing the number of seconds + * before or after 1970-01-01-T00:00Z. + */ +export const awsTimestamp = () => Math.floor(Date.now() / 1000); + +/** + * AWSDate - An extended ISO 8601 date string in the format YYYY-MM-DD. + * + * @param timezoneOffset - Timezone offset in hours, defaults to 0 + */ +export const awsDate = (timezoneOffset = 0) => + formattedTime(new Date(), '%Y-%m-%d', timezoneOffset); + +/** + * AWSTime - An extended ISO 8601 time string in the format hh:mm:ss.sss. + * + * @param timezoneOffset - Timezone offset in hours, defaults to 0 + */ +export const awsTime = (timezoneOffset = 0) => + formattedTime(new Date(), '%H:%M:%S.%f', timezoneOffset); + +/** + * AWSDateTime - An extended ISO 8601 date and time string in the format + * YYYY-MM-DDThh:mm:ss.sssZ. + * + * @param timezoneOffset - Timezone offset in hours, defaults to 0 + */ +export const awsDateTime = (timezoneOffset = 0) => + formattedTime(new Date(), '%Y-%m-%dT%H:%M:%S.%f', timezoneOffset); + +/** + * String formatted time with optional timezone offset + * + * @param now - Current Date object with zero timezone offset + * @param format - Date format function to apply before adding timezone offset + * @param timezoneOffset - Timezone offset in hours, defaults to 0 + */ +const formattedTime = ( + now: Date, + format: string, + timezoneOffset: number +): string => { + if (timezoneOffset < -12 || timezoneOffset > 14) { + // Reference: https://en.wikipedia.org/wiki/List_of_UTC_offsets + throw new RangeError( + 'timezoneOffset must be between -12 and +14 (inclusive)' + ); + } + const adjustedDate = new Date( + now.getTime() + timezoneOffset * 60 * 60 * 1000 + ); + + const formattedDateParts: Record = { + '%Y': adjustedDate.getUTCFullYear().toString(), + '%m': (adjustedDate.getUTCMonth() + 1).toString().padStart(2, '0'), + '%d': adjustedDate.getUTCDate().toString().padStart(2, '0'), + '%H': adjustedDate.getUTCHours().toString().padStart(2, '0'), + '%M': adjustedDate.getUTCMinutes().toString().padStart(2, '0'), + '%S': adjustedDate.getUTCSeconds().toString().padStart(2, '0'), + '.%f': `.${adjustedDate.getUTCMilliseconds().toString().padStart(3, '0')}`, + }; + + const dateTimeStr = format.replace( + /%Y|%m|%d|%H|%M|%S|\.%f/g, + (match) => formattedDateParts[match] + ); + + let postfix: string; + if (timezoneOffset === 0) { + postfix = 'Z'; + } else { + const sign = timezoneOffset > 0 ? '+' : '-'; + const absOffset = Math.abs(timezoneOffset); + const hours = Math.floor(absOffset); + const minutes = Math.floor((absOffset - hours) * 60); + postfix = `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; + } + + return `${dateTimeStr}${postfix}`; +}; diff --git a/packages/event-handler/tests/unit/appsync-graphql/scalarTypesUtils.test.ts b/packages/event-handler/tests/unit/appsync-graphql/scalarTypesUtils.test.ts new file mode 100644 index 0000000000..0671fa492b --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/scalarTypesUtils.test.ts @@ -0,0 +1,143 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + awsDate, + awsDateTime, + awsTime, + awsTimestamp, + makeId, +} from '../../../src/appsync-graphql/scalarTypesUtils.js'; + +const mockDate = new Date('2025-06-15T10:30:45.123Z'); +describe('Scalar Types Utils', () => { + beforeAll(() => { + vi.useFakeTimers().setSystemTime(mockDate); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + describe('makeId', () => { + it('should generate a valid UUID', () => { + const id = makeId(); + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(id).toMatch(uuidRegex); + }); + + it('should generate unique IDs', () => { + const id1 = makeId(); + const id2 = makeId(); + expect(id1).not.toBe(id2); + }); + }); + + describe('awsDate', () => { + it('should return a date in YYYY-MM-DD format with Z timezone', () => { + const result = awsDate(); + expect(result).toBe('2025-06-15Z'); + }); + + it('should handle positive timezone offset', () => { + const result = awsDate(5); + expect(result).toBe('2025-06-15+05:00:00'); + }); + + it('should handle negative timezone offset', () => { + const result = awsDate(-8); + expect(result).toBe('2025-06-15-08:00:00'); + }); + + it('should handle date change with timezone offset', () => { + const result = awsDate(-11); + expect(result).toBe('2025-06-14-11:00:00'); + }); + + it('should handle fractional timezone offset', () => { + const result = awsDate(5.5); + expect(result).toBe('2025-06-15+05:30:00'); + }); + + it('should handle negative fractional timezone offset', () => { + const result = awsDate(-9.5); + expect(result).toBe('2025-06-15-09:30:00'); + }); + + it('should throw RangeError for invalid timezone offset', () => { + expect(() => awsDate(15)).toThrow(RangeError); + expect(() => awsDate(-13)).toThrow(RangeError); + }); + }); + + describe('awsTime', () => { + it('should return a time in HH:MM:SS.sss format with Z timezone', () => { + const result = awsTime(); + expect(result).toBe('10:30:45.123Z'); + }); + + it('should handle positive timezone offset', () => { + const result = awsTime(3); + expect(result).toBe('13:30:45.123+03:00:00'); + }); + + it('should handle negative timezone offset', () => { + const result = awsTime(-5); + expect(result).toBe('05:30:45.123-05:00:00'); + }); + + it('should handle fractional timezone offset', () => { + const result = awsTime(5.5); + expect(result).toBe('16:00:45.123+05:30:00'); + }); + + it('should throw RangeError for invalid timezone offset', () => { + expect(() => awsTime(15)).toThrow(RangeError); + expect(() => awsTime(-13)).toThrow(RangeError); + }); + }); + + describe('awsDateTime', () => { + it('should return a datetime in ISO 8601 format with Z timezone', () => { + const result = awsDateTime(); + expect(result).toBe('2025-06-15T10:30:45.123Z'); + }); + + it('should handle positive timezone offset', () => { + const result = awsDateTime(2); + expect(result).toBe('2025-06-15T12:30:45.123+02:00:00'); + }); + + it('should handle negative timezone offset', () => { + const result = awsDateTime(-7); + expect(result).toBe('2025-06-15T03:30:45.123-07:00:00'); + }); + + it('should handle date/time change with timezone offset', () => { + const result = awsDateTime(-11); + expect(result).toBe('2025-06-14T23:30:45.123-11:00:00'); + }); + + it('should handle fractional timezone offset', () => { + const result = awsDateTime(5.5); + expect(result).toBe('2025-06-15T16:00:45.123+05:30:00'); + }); + + it('should handle negative fractional timezone offset', () => { + const result = awsDateTime(-9.5); + expect(result).toBe('2025-06-15T01:00:45.123-09:30:00'); + }); + + it('should throw RangeError for invalid timezone offset', () => { + expect(() => awsDateTime(15)).toThrow(RangeError); + expect(() => awsDateTime(-13)).toThrow(RangeError); + }); + }); + + describe('awsTimestamp', () => { + it('should return current time as Unix timestamp in seconds', () => { + const result = awsTimestamp(); + const expected = Math.floor(mockDate.getTime() / 1000); + expect(result).toBe(expected); + }); + }); +}); From b693f57c49c93d0a86fbda32cf60bb4fc125033b Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 20:29:55 +0600 Subject: [PATCH 37/49] fix: remove unnecessary checks in isAppSyncGraphQLEvent type guard --- packages/event-handler/src/appsync-graphql/utils.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/utils.ts b/packages/event-handler/src/appsync-graphql/utils.ts index e925d6b324..0a3f8bea91 100644 --- a/packages/event-handler/src/appsync-graphql/utils.ts +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -16,7 +16,6 @@ const isAppSyncGraphQLEvent = ( return false; } return ( - 'arguments' in event && isRecord(event.arguments) && 'identity' in event && 'source' in event && @@ -25,18 +24,12 @@ const isAppSyncGraphQLEvent = ( '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) ); }; From 67ab9113676b9893d9ab2b17e3cd53c0702ca2e8 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 20:40:30 +0600 Subject: [PATCH 38/49] refactor: update AppSyncGraphQLResolver to use unified resolver method for handling GraphQL events --- .../src/appsync-graphql/AppSyncGraphQLResolver.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 94e6adb7e0..02cf524fee 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -7,8 +7,7 @@ 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 + * This resolver is designed to handle GraphQL 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 @@ -17,13 +16,16 @@ import { isAppSyncGraphQLEvent } from './utils.js'; * * const app = new AppSyncGraphQLResolver(); * - * app.onQuery('getPost', async ({ id }) => { + * app.resolver(async ({ id }) => { * // your business logic here * return { * id, * title: 'Post Title', * content: 'Post Content', * }; + * }, { + * fieldName: 'getPost', + * typeName: 'Query' * }); * * export const handler = async (event, context) => @@ -40,13 +42,16 @@ export class AppSyncGraphQLResolver extends Router { * * const app = new AppSyncGraphQLResolver(); * - * app.onQuery('getPost', async ({ id }) => { + * app.resolver(async ({ id }) => { * // your business logic here * return { * id, * title: 'Post Title', * content: 'Post Content', * }; + * }, { + * fieldName: 'getPost', + * typeName: 'Query' * }); * * export const handler = async (event, context) => @@ -62,7 +67,7 @@ export class AppSyncGraphQLResolver extends Router { * const app = new AppSyncGraphQLResolver(); * * class Lambda { - * ⁣@app.onQuery('getPost') + * ⁣@app.resolver({ fieldName: 'getPost', typeName: 'Query' }) * async handleGetPost({ id }) { * // your business logic here * return { From 50c40b2026b6a1a9fb4fc5cb28b97c145dd819cc Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 20:40:38 +0600 Subject: [PATCH 39/49] refactor: update test descriptions for clarity on Query and Mutation handlers --- .../unit/appsync-graphql/AppSyncGraphQLResolver.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 e9419349bf..121fd98184 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -40,7 +40,7 @@ describe('Class: AppSyncGraphQLResolver', () => { expect(result).toBeUndefined(); }); - it('throws error if there are no onQuery handlers', async () => { + it('throws error if there are no handlers for `Query`', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); @@ -53,7 +53,7 @@ describe('Class: AppSyncGraphQLResolver', () => { expect(console.error).toHaveBeenCalled(); }); - it('throws error if there are no onMutation handlers', async () => { + it('throws error if there are no handlers for `Mutation`', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); @@ -66,7 +66,7 @@ describe('Class: AppSyncGraphQLResolver', () => { expect(console.error).toHaveBeenCalled(); }); - it('returns the response of the onQuery handler', async () => { + it('returns the response of the `Query` handler', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); app.resolver<{ id: string }>( @@ -96,7 +96,7 @@ describe('Class: AppSyncGraphQLResolver', () => { }); }); - it('returns the response of the onMutation handler', async () => { + it('returns the response of the `Mutation` handler', async () => { // Prepare const app = new AppSyncGraphQLResolver({ logger: console }); app.resolver<{ title: string; content: string }>( From 45d18ee07a0503b312ad5aa6481437d289ce2f0a Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 23:29:59 +0600 Subject: [PATCH 40/49] feat: enhance AppSyncGraphQLResolver to pass context to resolver handlers and update tests for event and context access --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 14 +++++++-- .../src/types/appsync-graphql.ts | 9 ++++-- .../AppSyncGraphQLResolver.test.ts | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 02cf524fee..84f8bb3868 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -101,7 +101,7 @@ export class AppSyncGraphQLResolver extends Router { return; } try { - return await this.#executeSingleResolver(event); + return await this.#executeSingleResolver(event, context); } catch (error) { this.logger.error( `An error occurred in handler ${event.info.fieldName}`, @@ -120,9 +120,13 @@ export class AppSyncGraphQLResolver extends Router { * with the event arguments. If no handler is found, it throws a `ResolverNotFoundException`. * * @param event - The AppSync GraphQL event containing resolver information. + * @param context - The Lambda execution context. * @throws {ResolverNotFoundException} If no resolver is registered for the given field and type. */ - async #executeSingleResolver(event: AppSyncGraphQLEvent): Promise { + async #executeSingleResolver( + event: AppSyncGraphQLEvent, + context: Context + ): Promise { const { fieldName, parentTypeName: typeName } = event.info; const resolverHandlerOptions = this.resolverRegistry.resolve( @@ -130,7 +134,11 @@ export class AppSyncGraphQLResolver extends Router { fieldName ); if (resolverHandlerOptions) { - return resolverHandlerOptions.handler.apply(this, [event.arguments]); + return resolverHandlerOptions.handler.apply(this, [ + event.arguments, + event, + context, + ]); } throw new ResolverNotFoundException( diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 64b765c296..659cfe17b5 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,14 +1,19 @@ +import type { Context } from 'aws-lambda'; import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; import type { GenericLogger } from './common.js'; // #region Resolver fn type ResolverSyncHandlerFn> = ( - args: TParams + args: TParams, + event: AppSyncGraphQLEvent, + context: Context ) => unknown; type ResolverHandlerFn> = ( - args: TParams + args: TParams, + event: AppSyncGraphQLEvent, + context: Context ) => Promise; type ResolverHandler> = diff --git a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts index 121fd98184..80295293da 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -1,4 +1,6 @@ import context from '@aws-lambda-powertools/testing-utils/context'; +import { Context } from 'aws-lambda'; +import { AppSyncGraphQLEvent } from 'src/types/appsync-graphql.js'; import { onGraphqlEventFactory } from 'tests/helpers/factories.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; @@ -174,6 +176,34 @@ describe('Class: AppSyncGraphQLResolver', () => { }); }); + it('resolver function has access to event and context', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + app.resolver<{ id: string }>( + async ({ id }, event, context) => { + return { + id, + event, + context, + }; + }, + { + fieldName: 'getPost', + } + ); + + // Act + const event = onGraphqlEventFactory('getPost', 'Query', { id: '123' }); + const result = await app.resolve(event, context); + + // Assess + expect(result).toStrictEqual({ + id: '123', + event, + context, + }); + }); + it('emits debug message when AWS_LAMBDA_LOG_LEVEL is set to DEBUG', async () => { // Prepare vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); From 0817c6b00011402cbdf563767e628f7df0d2493d Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Mon, 23 Jun 2025 23:34:26 +0600 Subject: [PATCH 41/49] refactor: remove unused imports in AppSyncGraphQLResolver test file --- .../tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts | 2 -- 1 file changed, 2 deletions(-) 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 80295293da..2fd3257307 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -1,6 +1,4 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import { Context } from 'aws-lambda'; -import { AppSyncGraphQLEvent } from 'src/types/appsync-graphql.js'; import { onGraphqlEventFactory } from 'tests/helpers/factories.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; From 8796757c1b5dee582549d1a0cb04390ad7680aeb Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 26 Jun 2025 10:11:09 +0600 Subject: [PATCH 42/49] fix: remove duplicate assertion for console.debug in AppSyncGraphQLResolver test --- .../tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts | 1 - 1 file changed, 1 deletion(-) 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 2fd3257307..4c6ff6f558 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -166,7 +166,6 @@ describe('Class: AppSyncGraphQLResolver', () => { // Assess expect(console.debug).not.toHaveBeenCalledWith(); - expect(console.debug).not.toHaveBeenCalledWith(); expect(result).toEqual({ id: '123', title: 'Post Title', From cb9242ef5c73a3beacb5cc3e6ca88076707c67be Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 26 Jun 2025 10:24:45 +0600 Subject: [PATCH 43/49] refactor: replace AppSyncGraphQLEvent with AppSyncResolverEvent for improved type consistency --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 7 ++-- .../src/appsync-graphql/utils.ts | 4 +- .../src/types/appsync-graphql.ts | 39 ++----------------- 3 files changed, 8 insertions(+), 42 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 84f8bb3868..76e639d4c7 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -1,5 +1,4 @@ -import type { Context } from 'aws-lambda'; -import type { AppSyncGraphQLEvent } from '../types/appsync-graphql.js'; +import type { AppSyncResolverEvent, Context } from 'aws-lambda'; import { Router } from './Router.js'; import { ResolverNotFoundException } from './errors.js'; import { isAppSyncGraphQLEvent } from './utils.js'; @@ -119,12 +118,12 @@ export class AppSyncGraphQLResolver extends Router { * from the resolver registry. 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. + * @param event - The AppSync resolver event containing the necessary information. * @param context - The Lambda execution context. * @throws {ResolverNotFoundException} If no resolver is registered for the given field and type. */ async #executeSingleResolver( - event: AppSyncGraphQLEvent, + event: AppSyncResolverEvent>, context: Context ): Promise { const { fieldName, parentTypeName: typeName } = event.info; diff --git a/packages/event-handler/src/appsync-graphql/utils.ts b/packages/event-handler/src/appsync-graphql/utils.ts index 0a3f8bea91..b748d8dfab 100644 --- a/packages/event-handler/src/appsync-graphql/utils.ts +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -1,5 +1,5 @@ import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; -import type { AppSyncGraphQLEvent } from '../types/appsync-graphql.js'; +import type { AppSyncResolverEvent } from 'aws-lambda'; /** * Type guard to check if the provided event is an AppSync GraphQL event. @@ -11,7 +11,7 @@ import type { AppSyncGraphQLEvent } from '../types/appsync-graphql.js'; */ const isAppSyncGraphQLEvent = ( event: unknown -): event is AppSyncGraphQLEvent => { +): event is AppSyncResolverEvent> => { if (typeof event !== 'object' || event === null || !isRecord(event)) { return false; } diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 659cfe17b5..4871a855f5 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,4 +1,4 @@ -import type { Context } from 'aws-lambda'; +import type { AppSyncResolverEvent, Context } from 'aws-lambda'; import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; import type { GenericLogger } from './common.js'; @@ -6,13 +6,13 @@ import type { GenericLogger } from './common.js'; type ResolverSyncHandlerFn> = ( args: TParams, - event: AppSyncGraphQLEvent, + event: AppSyncResolverEvent>, context: Context ) => unknown; type ResolverHandlerFn> = ( args: TParams, - event: AppSyncGraphQLEvent, + event: AppSyncResolverEvent>, context: Context ) => Promise; @@ -84,44 +84,11 @@ type GraphQlRouteOptions = { 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, ResolverHandler, }; From 0d1c061db49b1902f2f252e1fc6359f482a13b40 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 26 Jun 2025 10:29:27 +0600 Subject: [PATCH 44/49] refactor: simplify development mode check by using isDevMode utility function --- .../event-handler/src/appsync-graphql/Router.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 314c38359f..617bcde245 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -1,6 +1,8 @@ -import { EnvironmentVariablesService } from '@aws-lambda-powertools/commons'; import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; -import { getStringFromEnv } from '@aws-lambda-powertools/commons/utils/env'; +import { + getStringFromEnv, + isDevMode, +} from '@aws-lambda-powertools/commons/utils/env'; import type { GraphQlRouteOptions, GraphQlRouterOptions, @@ -26,13 +28,8 @@ class Router { * 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: '', @@ -45,7 +42,7 @@ class Router { this.resolverRegistry = new RouteHandlerRegistry({ logger: this.logger, }); - this.isDev = this.envService.isDevMode(); + this.isDev = isDevMode(); } /** From 1e1c1acee3121e71e90705f8952149039efcd100 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 26 Jun 2025 10:34:47 +0600 Subject: [PATCH 45/49] fix: update event type in resolver handler functions for improved type safety --- packages/event-handler/src/types/appsync-graphql.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 4871a855f5..2e31813019 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -6,13 +6,13 @@ import type { GenericLogger } from './common.js'; type ResolverSyncHandlerFn> = ( args: TParams, - event: AppSyncResolverEvent>, + event: AppSyncResolverEvent, context: Context ) => unknown; type ResolverHandlerFn> = ( args: TParams, - event: AppSyncResolverEvent>, + event: AppSyncResolverEvent, context: Context ) => Promise; From f12cb212335bb7c5c9b06e7df4d8b634cbfc8e67 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Thu, 26 Jun 2025 10:55:39 +0600 Subject: [PATCH 46/49] feat: add optional parameters to resolve method for enhanced flexibility --- .../appsync-graphql/AppSyncGraphQLResolver.ts | 16 +++++-- .../src/types/appsync-graphql.ts | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index 76e639d4c7..c3d8a9d128 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -1,4 +1,5 @@ import type { AppSyncResolverEvent, Context } from 'aws-lambda'; +import type { ResolveOptions } from '../types/appsync-graphql.js'; import { Router } from './Router.js'; import { ResolverNotFoundException } from './errors.js'; import { isAppSyncGraphQLEvent } from './utils.js'; @@ -87,8 +88,13 @@ export class AppSyncGraphQLResolver extends Router { * * @param event - The incoming event, which may be an AppSync GraphQL event or an array of events. * @param context - The Lambda execution context. + * @param options - Optional parameters for the resolver, such as the scope of the handler. */ - public async resolve(event: unknown, context: Context): Promise { + public async resolve( + event: unknown, + context: Context, + options?: ResolveOptions + ): Promise { if (Array.isArray(event)) { this.logger.warn('Batch resolver is not implemented yet'); return; @@ -100,7 +106,7 @@ export class AppSyncGraphQLResolver extends Router { return; } try { - return await this.#executeSingleResolver(event, context); + return await this.#executeSingleResolver(event, context, options); } catch (error) { this.logger.error( `An error occurred in handler ${event.info.fieldName}`, @@ -120,11 +126,13 @@ export class AppSyncGraphQLResolver extends Router { * * @param event - The AppSync resolver event containing the necessary information. * @param context - The Lambda execution context. + * @param options - Optional parameters for the resolver, such as the scope of the handler. * @throws {ResolverNotFoundException} If no resolver is registered for the given field and type. */ async #executeSingleResolver( event: AppSyncResolverEvent>, - context: Context + context: Context, + options?: ResolveOptions ): Promise { const { fieldName, parentTypeName: typeName } = event.info; @@ -133,7 +141,7 @@ export class AppSyncGraphQLResolver extends Router { fieldName ); if (resolverHandlerOptions) { - return resolverHandlerOptions.handler.apply(this, [ + return resolverHandlerOptions.handler.apply(options?.scope ?? this, [ event.arguments, event, context, diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts index 2e31813019..a6546a8ce6 100644 --- a/packages/event-handler/src/types/appsync-graphql.ts +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -1,7 +1,49 @@ import type { AppSyncResolverEvent, Context } from 'aws-lambda'; +import type { AppSyncGraphQLResolver } from '../appsync-graphql/AppSyncGraphQLResolver.js'; import type { RouteHandlerRegistry } from '../appsync-graphql/RouteHandlerRegistry.js'; import type { GenericLogger } from './common.js'; +// #region resolve options + +/** + * Optional object to pass to the {@link AppSyncGraphQLResolver.resolve | `AppSyncGraphQLResolver.resolve()`} method. + */ +type ResolveOptions = { + /** + * Reference to `this` instance of the class that is calling the `resolve` method. + * + * This parameter should be used when using {@link AppSyncGraphQLResolver.resolver | `AppSyncGraphQLResolver.resolver()`} + * as class method decorators, and it's used to bind the decorated methods to your class instance. + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * class Lambda { + * public scope = 'scoped'; + * + * @app.resolver({ fieldName: 'getPost', typeName: 'Query' }) + * public async handleGetPost({ id }) { + * // your business logic here + * return { + * id, + * title: `${this.scope} Post Title`, + * }; + * } + * + * public async handler(event, context) { + * return app.resolve(event, context, { scope: this }); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + */ + scope?: unknown; +}; + // #region Resolver fn type ResolverSyncHandlerFn> = ( @@ -91,4 +133,5 @@ export type { GraphQlRouterOptions, GraphQlRouteOptions, ResolverHandler, + ResolveOptions, }; From a4860ccf50e278e10eb950ceeecc577972aa67ab Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 29 Jun 2025 11:28:35 +0600 Subject: [PATCH 47/49] doc: bind decorated methods to class instance in resolver handler --- .../src/appsync-graphql/AppSyncGraphQLResolver.ts | 4 +++- packages/event-handler/src/appsync-graphql/Router.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts index c3d8a9d128..6e6cab9fac 100644 --- a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -78,7 +78,9 @@ export class AppSyncGraphQLResolver extends Router { * } * * async handler(event, context) { - * return app.resolve(event, context); + * return app.resolve(event, context, { + * scope: this, // bind decorated methods to the class instance + * }); * } * } * diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index 617bcde245..f56150ce51 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -113,7 +113,9 @@ class Router { * } * * async handler(event, context) { - * return app.resolve(event, context); + * return app.resolve(event, context, { + * scope: this, // bind decorated methods to the class instance + * }); * } * } * From cf0b699d6b12f5f854162249d9e9c0774ed5233a Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 29 Jun 2025 11:41:00 +0600 Subject: [PATCH 48/49] test: add scope preservation test for resolver decorator in AppSyncGraphQLResolver --- .../AppSyncGraphQLResolver.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 4c6ff6f558..ed3d06502c 100644 --- a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -1,4 +1,5 @@ import context from '@aws-lambda-powertools/testing-utils/context'; +import type { Context } from 'aws-lambda'; import { onGraphqlEventFactory } from 'tests/helpers/factories.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AppSyncGraphQLResolver } from '../../../src/appsync-graphql/AppSyncGraphQLResolver.js'; @@ -201,6 +202,45 @@ describe('Class: AppSyncGraphQLResolver', () => { }); }); + it('preserves the scope when decorating with `resolver`', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + class Lambda { + public scope = 'scoped'; + + @app.resolver({ fieldName: 'getPost', typeName: 'Query' }) + public async handleGetPost({ id }: { id: string }) { + return { + id, + scope: `${this.scope} id=${id}`, + }; + } + + public async handler(event: unknown, context: Context) { + return this.stuff(event, context); + } + + async stuff(event: unknown, context: Context) { + return app.resolve(event, context, { scope: this }); + } + } + const lambda = new Lambda(); + const handler = lambda.handler.bind(lambda); + + // Act + const result = await handler( + onGraphqlEventFactory('getPost', 'Query', { id: '123' }), + context + ); + + // Assess + expect(result).toEqual({ + id: '123', + scope: 'scoped id=123', + }); + }); + it('emits debug message when AWS_LAMBDA_LOG_LEVEL is set to DEBUG', async () => { // Prepare vi.stubEnv('AWS_LAMBDA_LOG_LEVEL', 'DEBUG'); From 08711324a506a86c183841e6b4b35256c2650d14 Mon Sep 17 00:00:00 2001 From: arnabrahman Date: Sun, 29 Jun 2025 12:08:07 +0600 Subject: [PATCH 49/49] fix: handle optional descriptor value in resolver registration for improved robustness --- packages/event-handler/src/appsync-graphql/Router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts index f56150ce51..00f02aa3f5 100644 --- a/packages/event-handler/src/appsync-graphql/Router.ts +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -151,12 +151,12 @@ class Router { } const resolverOptions = handler; - return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + return (target, _propertyKey, descriptor: PropertyDescriptor) => { const { typeName = 'Query', fieldName } = resolverOptions; this.resolverRegistry.register({ fieldName, - handler: descriptor.value, + handler: descriptor?.value, typeName, });