diff --git a/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts new file mode 100644 index 000000000..6e6cab9fa --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts @@ -0,0 +1,173 @@ +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'; + +/** + * Resolver for AWS AppSync GraphQL APIs. + * + * 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 + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * 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) => + * app.resolve(event, context); + * ``` + */ +export class AppSyncGraphQLResolver extends Router { + /** + * Resolve the response based on the provided event and route handlers configured. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * app.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) => + * 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.resolver({ fieldName: 'getPost', typeName: 'Query' }) + * async handleGetPost({ id }) { + * // your business logic here + * return { + * id, + * title: 'Post Title', + * content: 'Post Content', + * }; + * } + * + * async handler(event, context) { + * return app.resolve(event, context, { + * scope: this, // bind decorated methods to the class instance + * }); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param 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, + options?: ResolveOptions + ): Promise { + if (Array.isArray(event)) { + this.logger.warn('Batch resolver is not implemented yet'); + return; + } + if (!isAppSyncGraphQLEvent(event)) { + this.logger.warn( + 'Received an event that is not compatible with this resolver' + ); + return; + } + try { + return await this.#executeSingleResolver(event, context, options); + } catch (error) { + this.logger.error( + `An error occurred in handler ${event.info.fieldName}`, + error + ); + if (error instanceof ResolverNotFoundException) throw error; + return this.#formatErrorResponse(error); + } + } + + /** + * Executes the appropriate resolver for a given AppSync GraphQL event. + * + * This method attempts to resolve the handler for the specified field and type name + * 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 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, + options?: ResolveOptions + ): Promise { + const { fieldName, parentTypeName: typeName } = event.info; + + const resolverHandlerOptions = this.resolverRegistry.resolve( + typeName, + fieldName + ); + if (resolverHandlerOptions) { + return resolverHandlerOptions.handler.apply(options?.scope ?? this, [ + event.arguments, + event, + context, + ]); + } + + throw new ResolverNotFoundException( + `No resolver found for ${typeName}-${fieldName}` + ); + } + + /** + * Format the error response to be returned to the client. + * + * @param error - The error object + */ + #formatErrorResponse(error: unknown) { + if (error instanceof Error) { + return { + error: `${error.name} - ${error.message}`, + }; + } + return { + error: 'An unknown error occurred', + }; + } +} diff --git a/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts new file mode 100644 index 000000000..fde0969b6 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts @@ -0,0 +1,79 @@ +import type { + GenericLogger, + RouteHandlerOptions, + RouteHandlerRegistryOptions, +} from '../types/appsync-graphql.js'; + +/** + * 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. + */ +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; + + public constructor(options: RouteHandlerRegistryOptions) { + this.#logger = options.logger; + } + + /** + * Registers a new GraphQL route resolver for a specific type and field. + * + * @param options - The options for registering the route handler, including the GraphQL type name, field name, and the handler function. + * @param options.fieldName - The field name of the GraphQL type to be registered + * @param options.handler - The handler function to be called when the GraphQL event is received + * @param options.typeName - The name of the GraphQL type to be registered + * + */ + public register(options: RouteHandlerOptions): void { + const { fieldName, handler, typeName } = options; + this.#logger.debug(`Adding resolver for field ${typeName}.${fieldName}`); + const cacheKey = this.#makeKey(typeName, fieldName); + if (this.resolvers.has(cacheKey)) { + this.#logger.warn( + `A resolver for field '${fieldName}' is already registered for '${typeName}'. The previous resolver will be replaced.` + ); + } + this.resolvers.set(cacheKey, { + fieldName, + handler, + typeName, + }); + } + + /** + * Resolves the handler for a specific GraphQL API event. + * + * @param typeName - The name of the GraphQL type (e.g., "Query", "Mutation", or a custom type). + * @param fieldName - The name of the field within the specified type. + */ + public resolve( + typeName: string, + fieldName: string + ): RouteHandlerOptions | undefined { + this.#logger.debug( + `Looking for resolver for type=${typeName}, field=${fieldName}` + ); + return this.resolvers.get(this.#makeKey(typeName, fieldName)); + } + + /** + * Generates a unique key by combining the provided GraphQL type name and field name. + * + * @param typeName - The name of the GraphQL type. + * @param fieldName - The name of the GraphQL field. + */ + #makeKey(typeName: string, fieldName: string): string { + return `${typeName}.${fieldName}`; + } +} + +export { RouteHandlerRegistry }; diff --git a/packages/event-handler/src/appsync-graphql/Router.ts b/packages/event-handler/src/appsync-graphql/Router.ts new file mode 100644 index 000000000..00f02aa3f --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/Router.ts @@ -0,0 +1,168 @@ +import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import { + getStringFromEnv, + isDevMode, +} from '@aws-lambda-powertools/commons/utils/env'; +import type { + GraphQlRouteOptions, + GraphQlRouterOptions, + ResolverHandler, +} from '../types/appsync-graphql.js'; +import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; + +/** + * Class for registering resolvers for GraphQL events in AWS AppSync GraphQL APIs. + */ +class Router { + /** + * A map of registered routes for all GraphQL events, keyed by their fieldNames. + */ + protected readonly resolverRegistry: 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; + + public constructor(options?: GraphQlRouterOptions) { + const alcLogLevel = getStringFromEnv({ + key: 'AWS_LAMBDA_LOG_LEVEL', + defaultValue: '', + }); + this.logger = options?.logger ?? { + debug: alcLogLevel === 'DEBUG' ? console.debug : () => undefined, + error: console.error, + warn: console.warn, + }; + this.resolverRegistry = new RouteHandlerRegistry({ + logger: this.logger, + }); + this.isDev = isDevMode(); + } + + /** + * Register a resolver function for any GraphQL event. + * + * Registers a handler for a specific GraphQL field. The handler will be invoked when a request is made + * for the specified field. + * + * @example + * ```ts + * import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql'; + * + * const app = new AppSyncGraphQLResolver(); + * + * // Register a Query resolver + * app.resolver(async (payload) => { + * // your business logic here + * return payload; + * }, { + * fieldName: 'getPost' + * }); + * + * // Register a Mutation resolver + * app.resolver(async (payload) => { + * // your business logic here + * return payload; + * }, { + * fieldName: 'createPost', + * typeName: 'Mutation' + * }); + * + * 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.resolver<{ postId: string }>(async ({ postId }) => { + * // postId is now typed as string + * return { id: postId }; + * }, { + * 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.resolver({ fieldName: 'getPost' }) + * async handleGetPost(payload) { + * // your business logic here + * return payload; + * } + * + * async handler(event, context) { + * return app.resolve(event, context, { + * scope: this, // bind decorated methods to the class instance + * }); + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * @param handler - The handler function to be called when the event is received. + * @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 resolver>( + handler: ResolverHandler, + options: GraphQlRouteOptions + ): void; + public resolver(options: GraphQlRouteOptions): MethodDecorator; + public resolver>( + handler: ResolverHandler | GraphQlRouteOptions, + options?: GraphQlRouteOptions + ): MethodDecorator | undefined { + if (typeof handler === 'function') { + const resolverOptions = options as GraphQlRouteOptions; + const { typeName = 'Query', fieldName } = resolverOptions; + + this.resolverRegistry.register({ + fieldName, + handler: handler as ResolverHandler, + typeName, + }); + + return; + } + + const resolverOptions = handler; + return (target, _propertyKey, descriptor: PropertyDescriptor) => { + const { typeName = 'Query', fieldName } = resolverOptions; + + this.resolverRegistry.register({ + fieldName, + handler: descriptor?.value, + typeName, + }); + + return descriptor; + }; + } +} + +export { Router }; diff --git a/packages/event-handler/src/appsync-graphql/errors.ts b/packages/event-handler/src/appsync-graphql/errors.ts new file mode 100644 index 000000000..b3f3c15b9 --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/errors.ts @@ -0,0 +1,8 @@ +class ResolverNotFoundException extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ResolverNotFoundException'; + } +} + +export { ResolverNotFoundException }; diff --git a/packages/event-handler/src/appsync-graphql/scalarTypesUtils.ts b/packages/event-handler/src/appsync-graphql/scalarTypesUtils.ts new file mode 100644 index 000000000..1b7cce0a1 --- /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/src/appsync-graphql/utils.ts b/packages/event-handler/src/appsync-graphql/utils.ts new file mode 100644 index 000000000..b748d8dfa --- /dev/null +++ b/packages/event-handler/src/appsync-graphql/utils.ts @@ -0,0 +1,37 @@ +import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; +import type { AppSyncResolverEvent } from 'aws-lambda'; + +/** + * 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 AppSyncResolverEvent> => { + if (typeof event !== 'object' || event === null || !isRecord(event)) { + return false; + } + return ( + isRecord(event.arguments) && + 'identity' in event && + 'source' in event && + isRecord(event.request) && + isRecord(event.request.headers) && + 'domainName' in event.request && + 'prev' in event && + isRecord(event.info) && + isString(event.info.fieldName) && + isString(event.info.parentTypeName) && + isRecord(event.info.variables) && + Array.isArray(event.info.selectionSetList) && + event.info.selectionSetList.every((item) => isString(item)) && + isString(event.info.parentTypeName) && + isRecord(event.stash) + ); +}; + +export { isAppSyncGraphQLEvent }; diff --git a/packages/event-handler/src/types/appsync-graphql.ts b/packages/event-handler/src/types/appsync-graphql.ts new file mode 100644 index 000000000..a6546a8ce --- /dev/null +++ b/packages/event-handler/src/types/appsync-graphql.ts @@ -0,0 +1,137 @@ +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> = ( + args: TParams, + event: AppSyncResolverEvent, + context: Context +) => unknown; + +type ResolverHandlerFn> = ( + args: TParams, + event: AppSyncResolverEvent, + context: Context +) => Promise; + +type ResolverHandler> = + | ResolverSyncHandlerFn + | ResolverHandlerFn; + +// #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; +}; + +/** + * 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: ResolverHandler; + /** + * The field name of the event to be registered + */ + fieldName: string; + /** + * The type name of the event to be registered + */ + typeName: string; +}; + +// #region Router + +/** + * Options for the {@link Router} class + */ +type GraphQlRouterOptions = { + /** + * A logger instance to be used for logging debug, warning, and error messages. + * + * When no logger is provided, we'll only log warnings and errors using the global `console` object. + */ + logger?: GenericLogger; +}; + +/** + * Options for registering a route + */ +type GraphQlRouteOptions = { + /** + * The name of the field to be registered + */ + fieldName: string; + /** + * The type name of the event to be registered + */ + typeName?: string; +}; + +export type { + GenericLogger, + RouteHandlerRegistryOptions, + RouteHandlerOptions, + GraphQlRouterOptions, + GraphQlRouteOptions, + ResolverHandler, + ResolveOptions, +}; diff --git a/packages/event-handler/tests/helpers/factories.ts b/packages/event-handler/tests/helpers/factories.ts index a439da81a..8f3d8fd32 100644 --- a/packages/event-handler/tests/helpers/factories.ts +++ b/packages/event-handler/tests/helpers/factories.ts @@ -74,4 +74,38 @@ 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 onGraphqlEventFactory = ( + fieldName: string, + typeName: 'Query' | 'Mutation', + args: Record = {} +) => createEventFactory(fieldName, args, typeName); + +export { + onPublishEventFactory, + onSubscribeEventFactory, + onGraphqlEventFactory, +}; diff --git a/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts new file mode 100644 index 000000000..ed3d06502 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/AppSyncGraphQLResolver.test.ts @@ -0,0 +1,322 @@ +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'; +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( + [onGraphqlEventFactory('getPost', 'Query')], + context + ); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Batch resolver is not implemented yet' + ); + expect(result).toBeUndefined(); + }); + + it('logs a warning and returns early if the event is not compatible', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act + const result = await app.resolve(null, context); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Received an event that is not compatible with this resolver' + ); + expect(result).toBeUndefined(); + }); + + it('throws error if there are no handlers for `Query`', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act && Assess + await expect( + app.resolve(onGraphqlEventFactory('getPost', 'Query'), context) + ).rejects.toThrow( + new ResolverNotFoundException('No resolver found for Query-getPost') + ); + expect(console.error).toHaveBeenCalled(); + }); + + it('throws error if there are no handlers for `Mutation`', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + + // Act && Assess + await expect( + app.resolve(onGraphqlEventFactory('addPost', 'Mutation'), context) + ).rejects.toThrow( + new ResolverNotFoundException('No resolver found for Mutation-addPost') + ); + expect(console.error).toHaveBeenCalled(); + }); + + it('returns the response of the `Query` handler', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + app.resolver<{ id: string }>( + async ({ id }) => { + return { + id, + title: 'Post Title', + content: 'Post Content', + }; + }, + { + fieldName: 'getPost', + } + ); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('getPost', 'Query', { id: '123' }), + context + ); + + // Assess + expect(result).toEqual({ + id: '123', + title: 'Post Title', + content: 'Post Content', + }); + }); + + it('returns the response of the `Mutation` handler', async () => { + // Prepare + const app = new AppSyncGraphQLResolver({ logger: console }); + 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).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Mutation.addPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Looking for resolver for type=Mutation, field=addPost' + ); + expect(result).toEqual({ + id: '123', + title: 'Post Title', + content: 'Post Content', + }); + }); + + 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(result).toEqual({ + id: '123', + title: 'Post Title', + content: 'Post Content', + }); + }); + + 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('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'); + 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', + 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.resolver( + async () => { + throw error; + }, + { + fieldName: 'addPost', + typeName: 'Mutation', + } + ); + + // Act + const result = await app.resolve( + onGraphqlEventFactory('addPost', 'Mutation', { + title: 'Post Title', + content: 'Post Content', + }), + context + ); + + // Assess + expect(result).toEqual({ + error: message, + }); + } + ); +}); diff --git a/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts new file mode 100644 index 000000000..b333b91d4 --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/RouteHandlerRegistry.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RouteHandlerRegistry } from '../../../src/appsync-graphql/RouteHandlerRegistry.js'; +import type { RouteHandlerOptions } from '../../../src/types/appsync-graphql.js'; +describe('Class: RouteHandlerRegistry', () => { + class MockRouteHandlerRegistry extends RouteHandlerRegistry { + public declare resolvers: Map; + } + + const getRegistry = () => new MockRouteHandlerRegistry({ logger: console }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { fieldName: 'getPost', typeName: 'Query' }, + { fieldName: 'addPost', typeName: 'Mutation' }, + ])( + 'registers a route handler for a field $fieldName', + ({ fieldName, typeName }) => { + // Prepare + const registry = getRegistry(); + + // Act + registry.register({ + fieldName, + typeName, + handler: vi.fn(), + }); + + // Assess + expect(registry.resolvers.size).toBe(1); + expect(registry.resolvers.get(`${typeName}.${fieldName}`)).toBeDefined(); + } + ); + + it('logs a warning and replaces the previous resolver if the field & type is already registered', () => { + // Prepare + const registry = getRegistry(); + const originalHandler = vi.fn(); + const otherHandler = vi.fn(); + + // Act + registry.register({ + fieldName: 'getPost', + typeName: 'Query', + handler: originalHandler, + }); + registry.register({ + fieldName: 'getPost', + typeName: 'Query', + handler: otherHandler, + }); + + // Assess + expect(registry.resolvers.size).toBe(1); + expect(registry.resolvers.get('Query.getPost')).toEqual({ + fieldName: 'getPost', + typeName: 'Query', + handler: otherHandler, + }); + expect(console.warn).toHaveBeenCalledWith( + "A resolver for field 'getPost' is already registered for 'Query'. The previous resolver will be replaced." + ); + }); + + it('will not replace the resolver if the event type is different', () => { + // Prepare + const registry = getRegistry(); + const originalHandler = vi.fn(); + const otherHandler = vi.fn(); + + // Act + registry.register({ + fieldName: 'getPost', + typeName: 'Query', + handler: originalHandler, + }); + registry.register({ + fieldName: 'getPost', + typeName: 'Mutation', // Different type + handler: otherHandler, + }); + + // Assess + expect(registry.resolvers.size).toBe(2); + expect(registry.resolvers.get('Query.getPost')).toEqual({ + fieldName: 'getPost', + typeName: 'Query', + handler: originalHandler, + }); + expect(registry.resolvers.get('Mutation.getPost')).toEqual({ + fieldName: 'getPost', + typeName: 'Mutation', + handler: otherHandler, + }); + }); +}); diff --git a/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts new file mode 100644 index 000000000..b515e299b --- /dev/null +++ b/packages/event-handler/tests/unit/appsync-graphql/Router.test.ts @@ -0,0 +1,144 @@ +import { Router } from 'src/appsync-graphql/Router.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('Class: Router', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers resolvers using the functional approach', () => { + // Prepare + const router = new Router({ logger: console }); + const getPost = vi.fn(() => [true]); + const addPost = vi.fn(async () => true); + + // Act + router.resolver(getPost, { typeName: 'Query', fieldName: 'getPost' }); + router.resolver(addPost, { typeName: 'Mutation', fieldName: 'addPost' }); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Query.getPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding resolver for field Mutation.addPost' + ); + }); + + it('registers resolvers using the decorator pattern', () => { + // Prepare + const router = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @router.resolver({ fieldName: 'getPost' }) + public getPost() { + return `${this.prop} foo`; + } + + @router.resolver({ fieldName: 'getAuthor', typeName: 'Query' }) + public getAuthor() { + return `${this.prop} bar`; + } + + @router.resolver({ fieldName: 'addPost', typeName: 'Mutation' }) + public addPost() { + return `${this.prop} bar`; + } + + @router.resolver({ fieldName: 'updatePost', typeName: 'Mutation' }) + public updatePost() { + return `${this.prop} baz`; + } + } + const lambda = new Lambda(); + const res1 = lambda.getPost(); + const res2 = lambda.getAuthor(); + const res3 = lambda.addPost(); + const res4 = lambda.updatePost(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Query.getPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding resolver for field Query.getAuthor' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 3, + 'Adding resolver for field Mutation.addPost' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 4, + 'Adding resolver for field Mutation.updatePost' + ); + + // verify that class scope is preserved after decorating + expect(res1).toBe('value foo'); + expect(res2).toBe('value bar'); + expect(res3).toBe('value bar'); + expect(res4).toBe('value baz'); + }); + + it('registers nested resolvers using the decorator pattern', () => { + // Prepare + const router = new Router({ logger: console }); + + // Act + class Lambda { + readonly prop = 'value'; + + @router.resolver({ fieldName: 'listLocations' }) + @router.resolver({ fieldName: 'locations' }) + public getLocations() { + return [{ name: 'Location 1', description: 'Description 1' }]; + } + } + const lambda = new Lambda(); + const response = lambda.getLocations(); + + // Assess + expect(console.debug).toHaveBeenNthCalledWith( + 1, + 'Adding resolver for field Query.locations' + ); + expect(console.debug).toHaveBeenNthCalledWith( + 2, + 'Adding resolver for field Query.listLocations' + ); + + expect(response).toEqual([ + { name: 'Location 1', description: 'Description 1' }, + ]); + }); + + it('uses a default logger with only warnings if none is provided', () => { + // Prepare + const router = new Router(); + + // Act + router.resolver(vi.fn(), { fieldName: 'getPost' }); + + // 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.resolver(vi.fn(), { fieldName: 'getPost' }); + + // Assess + expect(console.debug).toHaveBeenCalled(); + process.env.AWS_LAMBDA_LOG_LEVEL = undefined; + }); +}); 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 000000000..0671fa492 --- /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); + }); + }); +});