diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 9119b9c26f..c21019476e 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -3,7 +3,7 @@ import { getStringFromEnv, isDevMode, } from '@aws-lambda-powertools/commons/utils/env'; -import type { Context } from 'aws-lambda'; +import type { APIGatewayProxyResult, Context } from 'aws-lambda'; import type { ResolveOptions } from '../types/index.js'; import type { ErrorConstructor, @@ -15,15 +15,22 @@ import type { RouteOptions, RouterOptions, } from '../types/rest.js'; -import { HttpVerbs } from './constants.js'; +import { HttpErrorCodes, HttpVerbs } from './constants.js'; +import { + handlerResultToProxyResult, + proxyEventToWebRequest, + responseToProxyResult, +} from './converters.js'; import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js'; import { + InternalServerError, MethodNotAllowedError, NotFoundError, ServiceError, } from './errors.js'; import { Route } from './Route.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; +import { isAPIGatewayProxyEvent, isHttpMethod } from './utils.js'; abstract class BaseRouter { protected context: Record; @@ -133,11 +140,72 @@ abstract class BaseRouter { }; } - public abstract resolve( + /** + * Resolves an API Gateway event by routing it to the appropriate handler + * and converting the result to an API Gateway proxy result. Handles errors + * using registered error handlers or falls back to default error handling + * (500 Internal Server Error). + * + * @param event - The Lambda event to resolve + * @param context - The Lambda context + * @param options - Optional resolve options for scope binding + * @returns An API Gateway proxy result or undefined for incompatible events + */ + public async resolve( event: unknown, context: Context, options?: ResolveOptions - ): Promise; + ): Promise { + if (!isAPIGatewayProxyEvent(event)) { + this.logger.error( + 'Received an event that is not compatible with this resolver' + ); + throw new InternalServerError(); + } + + const method = event.requestContext.httpMethod.toUpperCase(); + if (!isHttpMethod(method)) { + this.logger.error(`HTTP method ${method} is not supported.`); + // We can't throw a MethodNotAllowedError outside the try block as it + // will be converted to an internal server error by the API Gateway runtime + return { + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + body: '', + }; + } + + const request = proxyEventToWebRequest(event); + + try { + const path = new URL(request.url).pathname as Path; + + const route = this.routeRegistry.resolve(method, path); + + if (route === null) { + throw new NotFoundError(`Route ${path} for method ${method} not found`); + } + + const result = await route.handler.apply(options?.scope ?? this, [ + route.params, + { + event, + context, + request, + }, + ]); + + return await handlerResultToProxyResult(result); + } catch (error) { + this.logger.debug(`There was an error processing the request: ${error}`); + const result = await this.handleError(error as Error, { + request, + event, + context, + scope: options?.scope, + }); + return await responseToProxyResult(result); + } + } public route(handler: RouteHandler, options: RouteOptions): void { const { method, path } = options; diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index 9c7cdbe825..f5484e5f7d 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -1,5 +1,14 @@ -import type { APIGatewayProxyEvent } from 'aws-lambda'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import type { HandlerResponse } from '../types/rest.js'; +import { isAPIGatewayProxyResult } from './utils.js'; +/** + * Creates a request body from API Gateway event body, handling base64 decoding if needed. + * + * @param body - The raw body from the API Gateway event + * @param isBase64Encoded - Whether the body is base64 encoded + * @returns The decoded body string or null + */ const createBody = (body: string | null, isBase64Encoded: boolean) => { if (body === null) return null; @@ -9,21 +18,33 @@ const createBody = (body: string | null, isBase64Encoded: boolean) => { return Buffer.from(body, 'base64').toString('utf8'); }; -export const proxyEventToWebRequest = (event: APIGatewayProxyEvent) => { - const { httpMethod, path, domainName } = event.requestContext; +/** + * Converts an API Gateway proxy event to a Web API Request object. + * + * @param event - The API Gateway proxy event + * @returns A Web API Request object + */ +export const proxyEventToWebRequest = ( + event: APIGatewayProxyEvent +): Request => { + const { httpMethod, path } = event; + const { domainName } = event.requestContext; const headers = new Headers(); for (const [name, value] of Object.entries(event.headers ?? {})) { - if (value != null) headers.append(name, value); + if (value != null) headers.set(name, value); } for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) { for (const value of values ?? []) { - headers.append(name, value); + const headerValue = headers.get(name); + if (!headerValue?.includes(value)) { + headers.append(name, value); + } } } const hostname = headers.get('Host') ?? domainName; - const protocol = headers.get('X-Forwarded-Proto') ?? 'http'; + const protocol = headers.get('X-Forwarded-Proto') ?? 'https'; const url = new URL(path, `${protocol}://${hostname}/`); @@ -45,4 +66,58 @@ export const proxyEventToWebRequest = (event: APIGatewayProxyEvent) => { headers, body: createBody(event.body, event.isBase64Encoded), }); -} +}; + +/** + * Converts a Web API Response object to an API Gateway proxy result. + * + * @param response - The Web API Response object + * @returns An API Gateway proxy result + */ +export const responseToProxyResult = async ( + response: Response +): Promise => { + const headers: Record = {}; + const multiValueHeaders: Record> = {}; + + for (const [key, value] of response.headers.entries()) { + const values = value.split(',').map((v) => v.trimStart()); + if (values.length > 1) { + multiValueHeaders[key] = values; + } else { + headers[key] = value; + } + } + + return { + statusCode: response.status, + headers, + multiValueHeaders, + body: await response.text(), + isBase64Encoded: false, + }; +}; + +/** + * Converts a handler response to an API Gateway proxy result. + * Handles APIGatewayProxyResult, Response objects, and plain objects. + * + * @param response - The handler response (APIGatewayProxyResult, Response, or plain object) + * @returns An API Gateway proxy result + */ +export const handlerResultToProxyResult = async ( + response: HandlerResponse +): Promise => { + if (isAPIGatewayProxyResult(response)) { + return response; + } + if (response instanceof Response) { + return await responseToProxyResult(response); + } + return { + statusCode: 200, + body: JSON.stringify(response), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }; +}; diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index adca15ad66..57bf54197f 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -1,7 +1,17 @@ import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; -import type { APIGatewayProxyEvent } from 'aws-lambda'; -import type { CompiledRoute, Path, ValidationResult } from '../types/rest.js'; -import { PARAM_PATTERN, SAFE_CHARS, UNSAFE_CHARS } from './constants.js'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import type { + CompiledRoute, + HttpMethod, + Path, + ValidationResult, +} from '../types/rest.js'; +import { + HttpVerbs, + PARAM_PATTERN, + SAFE_CHARS, + UNSAFE_CHARS, +} from './constants.js'; export function compilePath(path: Path): CompiledRoute { const paramNames: string[] = []; @@ -68,3 +78,30 @@ export const isAPIGatewayProxyEvent = ( (event.body === null || isString(event.body)) ); }; + +export const isHttpMethod = (method: string): method is HttpMethod => { + return Object.keys(HttpVerbs).includes(method); +}; + +/** + * Type guard to check if the provided result is an API Gateway Proxy result. + * + * We use this function to ensure that the result is an object and has the + * required properties without adding a dependency. + * + * @param result - The result to check + */ +export const isAPIGatewayProxyResult = ( + result: unknown +): result is APIGatewayProxyResult => { + if (!isRecord(result)) return false; + return ( + typeof result.statusCode === 'number' && + isString(result.body) && + (result.headers === undefined || isRecord(result.headers)) && + (result.multiValueHeaders === undefined || + isRecord(result.multiValueHeaders)) && + (result.isBase64Encoded === undefined || + typeof result.isBase64Encoded === 'boolean') + ); +}; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index fe258fa057..7733dd9aac 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -24,7 +24,7 @@ type ErrorResolveOptions = RequestOptions & ResolveOptions; type ErrorHandler = ( error: T, - options?: RequestOptions + options: RequestOptions ) => Promise; interface ErrorConstructor { @@ -53,10 +53,12 @@ interface CompiledRoute { type DynamicRoute = Route & CompiledRoute; +type HandlerResponse = Response | JSONObject; + type RouteHandler< TParams = Record, - TReturn = Response | JSONObject, -> = (args: TParams, options?: RequestOptions) => Promise; + TReturn = HandlerResponse, +> = (args: TParams, options: RequestOptions) => Promise; type HttpMethod = keyof typeof HttpVerbs; @@ -106,6 +108,7 @@ export type { ErrorHandlerRegistryOptions, ErrorHandler, ErrorResolveOptions, + HandlerResponse, HttpStatusCode, HttpMethod, Path, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index a351c02e38..114b76ce25 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -3,17 +3,14 @@ import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseRouter } from '../../../src/rest/BaseRouter.js'; import { HttpErrorCodes, HttpVerbs } from '../../../src/rest/constants.js'; -import { proxyEventToWebRequest } from '../../../src/rest/converters.js'; import { BadRequestError, InternalServerError, MethodNotAllowedError, - NotFoundError, + type NotFoundError, } from '../../../src/rest/errors.js'; -import { isAPIGatewayProxyEvent } from '../../../src/rest/utils.js'; import type { HttpMethod, - Path, RouteHandler, RouterOptions, } from '../../../src/types/rest.js'; @@ -32,7 +29,11 @@ const createTestEvent = ( queryStringParameters: null, multiValueQueryStringParameters: null, stageVariables: null, - requestContext: {} as any, + requestContext: { + httpMethod, + path, + domainName: 'localhost', + } as any, resource: '', }); @@ -44,33 +45,6 @@ describe('Class: BaseRouter', () => { this.logger.warn('test warn'); this.logger.error('test error'); } - - public async resolve( - event: unknown, - context: Context, - options?: any - ): Promise { - if (!isAPIGatewayProxyEvent(event)) - throw new Error('not an API Gateway event!'); - const { httpMethod: method, path } = event; - const route = this.routeRegistry.resolve( - method as HttpMethod, - path as Path - ); - const request = proxyEventToWebRequest(event); - try { - if (route == null) - throw new NotFoundError(`Route ${method} ${path} not found`); - return await route.handler(route.params, { request, event, context }); - } catch (error) { - return await this.handleError(error as Error, { - request, - event, - context, - ...options, - }); - } - } } beforeEach(() => { @@ -95,14 +69,33 @@ describe('Class: BaseRouter', () => { ) => void )('/test', async () => ({ result: `${verb}-test` })); // Act - const actual = (await app.resolve( - createTestEvent('/test', method), - context - )) as Response; + const actual = await app.resolve(createTestEvent('/test', method), context); // Assess - expect(actual).toEqual({ result: `${verb}-test` }); + expect(actual).toEqual({ + statusCode: 200, + body: JSON.stringify({ result: `${verb}-test` }), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); }); + it.each([['CONNECT'], ['TRACE']])( + 'throws MethodNotAllowedError for %s requests', + async (method) => { + // Prepare + const app = new TestResolver(); + + // Act & Assess + const result = await app.resolve( + createTestEvent('/test', method), + context + ); + + expect(result?.statusCode).toBe(HttpErrorCodes.METHOD_NOT_ALLOWED); + expect(result?.body).toEqual(''); + } + ); + it('accepts multiple HTTP methods', async () => { // Act const app = new TestResolver(); @@ -122,8 +115,14 @@ describe('Class: BaseRouter', () => { ); // Assess - expect(getResult).toEqual({ result: 'route-test' }); - expect(postResult).toEqual({ result: 'route-test' }); + const expectedResult = { + statusCode: 200, + body: JSON.stringify({ result: 'route-test' }), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }; + expect(getResult).toEqual(expectedResult); + expect(postResult).toEqual(expectedResult); }); it('uses the global console when no logger is not provided', () => { @@ -241,7 +240,12 @@ describe('Class: BaseRouter', () => { context ); // Assess - expect(actual).toEqual(expected); + expect(actual).toEqual({ + statusCode: 200, + body: JSON.stringify(expected), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); }); }); @@ -262,21 +266,23 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', message: 'Handled: test error', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('calls notFound handler when route is not found', async () => { @@ -290,21 +296,23 @@ describe('Class: BaseRouter', () => { })); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/nonexistent', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.NOT_FOUND); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.NOT_FOUND, + body: JSON.stringify({ statusCode: HttpErrorCodes.NOT_FOUND, error: 'Not Found', - message: 'Custom: Route GET /nonexistent not found', - }) - ); + message: 'Custom: Route /nonexistent for method GET not found', + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('calls methodNotAllowed handler when MethodNotAllowedError is thrown', async () => { @@ -322,21 +330,23 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.METHOD_NOT_ALLOWED); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + body: JSON.stringify({ statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, error: 'Method Not Allowed', message: 'Custom: POST not allowed', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('falls back to default error handler when registered handler throws', async () => { @@ -352,15 +362,14 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); - const body = await result.json(); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + const body = JSON.parse(result?.body ?? '{}'); expect(body.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); expect(body.error).toBe('Internal Server Error'); expect(body.message).toBe('Internal Server Error'); @@ -375,15 +384,14 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); - const body = await result.json(); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + const body = JSON.parse(result?.body ?? '{}'); expect(body.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); expect(body.error).toBe('Internal Server Error'); expect(body.message).toBe('Internal Server Error'); @@ -410,21 +418,23 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', message: 'Specific handler', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('uses ServiceError toJSON method when no custom handler is registered', async () => { @@ -436,21 +446,23 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR, + body: JSON.stringify({ statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR, error: 'InternalServerError', message: 'service error', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('hides error details when POWERTOOLS_DEV env var is not set', async () => { @@ -462,15 +474,14 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); - const body = await result.json(); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + const body = JSON.parse(result?.body ?? '{}'); expect(body.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); expect(body.error).toBe('Internal Server Error'); expect(body.message).toBe('Internal Server Error'); @@ -488,15 +499,14 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); - const body = await result.json(); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + const body = JSON.parse(result?.body ?? '{}'); expect(body.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); expect(body.error).toBe('Internal Server Error'); expect(body.message).toBe('debug error details'); @@ -527,29 +537,41 @@ describe('Class: BaseRouter', () => { }); // Act - const badResult = (await app.resolve( + const badResult = await app.resolve( createTestEvent('/bad', 'GET'), context - )) as Response; - const methodResult = (await app.resolve( + ); + const methodResult = await app.resolve( createTestEvent('/method', 'GET'), context - )) as Response; + ); // Assess - expect(badResult.status).toBe(HttpErrorCodes.UNPROCESSABLE_ENTITY); - expect(await badResult.json()).toEqual({ + const expectedBadResult = { statusCode: HttpErrorCodes.UNPROCESSABLE_ENTITY, - error: 'Validation Error', - message: 'Array handler: bad request', - }); - - expect(methodResult.status).toBe(HttpErrorCodes.UNPROCESSABLE_ENTITY); - expect(await methodResult.json()).toEqual({ + body: JSON.stringify({ + statusCode: HttpErrorCodes.UNPROCESSABLE_ENTITY, + error: 'Validation Error', + message: 'Array handler: bad request', + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }; + const expectedMethodResult = { statusCode: HttpErrorCodes.UNPROCESSABLE_ENTITY, - error: 'Validation Error', - message: 'Array handler: method not allowed', - }); + body: JSON.stringify({ + statusCode: HttpErrorCodes.UNPROCESSABLE_ENTITY, + error: 'Validation Error', + message: 'Array handler: method not allowed', + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }; + + expect(badResult).toEqual(expectedBadResult); + expect(methodResult).toEqual(expectedMethodResult); }); it('replaces previous handler when registering new handler for same error type', async () => { @@ -573,17 +595,22 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result.status).toBe(HttpErrorCodes.UNPROCESSABLE_ENTITY); - expect(await result.json()).toEqual({ + expect(result).toEqual({ statusCode: HttpErrorCodes.UNPROCESSABLE_ENTITY, - error: 'Second Handler', - message: 'second: test error', + body: JSON.stringify({ + statusCode: HttpErrorCodes.UNPROCESSABLE_ENTITY, + error: 'Second Handler', + message: 'second: test error', + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, }); }); @@ -602,13 +629,13 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve( + const result = await app.resolve( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result.headers.get('Content-Type')).toBe('application/json'); + expect(result?.headers?.['content-type']).toBe('application/json'); }); }); @@ -640,21 +667,23 @@ describe('Class: BaseRouter', () => { const lambda = new Lambda(); // Act - const result = (await lambda.handler( + const result = await lambda.handler( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', message: 'Decorated: test error', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('works with notFound decorator', async () => { @@ -679,21 +708,23 @@ describe('Class: BaseRouter', () => { const lambda = new Lambda(); // Act - const result = (await lambda.handler( + const result = await lambda.handler( createTestEvent('/nonexistent', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.NOT_FOUND); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.NOT_FOUND, + body: JSON.stringify({ statusCode: HttpErrorCodes.NOT_FOUND, error: 'Not Found', - message: 'Decorated: Route GET /nonexistent not found', - }) - ); + message: 'Decorated: Route /nonexistent for method GET not found', + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('works with methodNotAllowed decorator', async () => { @@ -723,21 +754,23 @@ describe('Class: BaseRouter', () => { const lambda = new Lambda(); // Act - const result = (await lambda.handler( + const result = await lambda.handler( createTestEvent('/test', 'GET'), context - )) as Response; + ); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.METHOD_NOT_ALLOWED); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, + body: JSON.stringify({ statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED, error: 'Method Not Allowed', message: 'Decorated: POST not allowed', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); it('preserves scope when using error handler decorators', async () => { @@ -770,21 +803,20 @@ describe('Class: BaseRouter', () => { const handler = lambda.handler.bind(lambda); // Act - const result = (await handler( - createTestEvent('/test', 'GET'), - context - )) as Response; + const result = await handler(createTestEvent('/test', 'GET'), context); // Assess - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(HttpErrorCodes.BAD_REQUEST); - expect(await result.text()).toBe( - JSON.stringify({ + expect(result).toEqual({ + statusCode: HttpErrorCodes.BAD_REQUEST, + body: JSON.stringify({ statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', message: 'scoped: test error', - }) - ); + }), + headers: { 'content-type': 'application/json' }, + multiValueHeaders: {}, + isBase64Encoded: false, + }); }); }); @@ -796,14 +828,15 @@ describe('Class: BaseRouter', () => { app.get('/test', async (_params, options) => { return { - hasRequest: options?.request instanceof Request, - hasEvent: options?.event === testEvent, - hasContext: options?.context === context, + hasRequest: options.request instanceof Request, + hasEvent: options.event === testEvent, + hasContext: options.context === context, }; }); // Act - const actual = (await app.resolve(testEvent, context)) as any; + const result = await app.resolve(testEvent, context); + const actual = JSON.parse(result?.body ?? '{}'); // Assess expect(actual.hasRequest).toBe(true); @@ -820,9 +853,9 @@ describe('Class: BaseRouter', () => { statusCode: HttpErrorCodes.BAD_REQUEST, error: 'Bad Request', message: error.message, - hasRequest: options?.request instanceof Request, - hasEvent: options?.event === testEvent, - hasContext: options?.context === context, + hasRequest: options.request instanceof Request, + hasEvent: options.event === testEvent, + hasContext: options.context === context, })); app.get('/test', () => { @@ -830,8 +863,8 @@ describe('Class: BaseRouter', () => { }); // Act - const result = (await app.resolve(testEvent, context)) as Response; - const body = await result.json(); + const result = await app.resolve(testEvent, context); + const body = JSON.parse(result?.body ?? '{}'); // Assess expect(body.hasRequest).toBe(true); @@ -848,9 +881,9 @@ describe('Class: BaseRouter', () => { @app.get('/test') public async getTest(_params: any, options: any) { return { - hasRequest: options?.request instanceof Request, - hasEvent: options?.event === testEvent, - hasContext: options?.context === context, + hasRequest: options.request instanceof Request, + hasEvent: options.event === testEvent, + hasContext: options.context === context, }; } @@ -862,7 +895,8 @@ describe('Class: BaseRouter', () => { const lambda = new Lambda(); // Act - const actual = (await lambda.handler(testEvent, context)) as any; + const result = await lambda.handler(testEvent, context); + const actual = JSON.parse(result?.body ?? '{}'); // Assess expect(actual.hasRequest).toBe(true); @@ -901,13 +935,102 @@ describe('Class: BaseRouter', () => { const lambda = new Lambda(); // Act - const result = (await lambda.handler(testEvent, context)) as Response; - const body = await result.json(); + const result = await lambda.handler(testEvent, context); + const body = JSON.parse(result?.body ?? '{}'); // Assess expect(body.hasRequest).toBe(true); expect(body.hasEvent).toBe(true); expect(body.hasContext).toBe(true); }); + + it('preserves scope when using route handler decorators', async () => { + // Prepare + const app = new TestResolver(); + + class Lambda { + public scope = 'scoped'; + + @app.get('/test') + public async getTest() { + return { + message: `${this.scope}: success`, + }; + } + + public async handler(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(createTestEvent('/test', 'GET'), context); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ + message: 'scoped: success', + }), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); + }); + }); + + describe('resolve method', () => { + it('throws an internal server error for non-API Gateway events', async () => { + // Prepare + const app = new TestResolver(); + const nonApiGatewayEvent = { Records: [] }; // SQS-like event + + // Act & Assess + expect(app.resolve(nonApiGatewayEvent, context)).rejects.toThrowError( + InternalServerError + ); + }); + + it('returns APIGatewayProxyResult for successful requests', async () => { + // Prepare + const app = new TestResolver(); + app.get('/test', async () => ({ success: true })); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ success: true }), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it('returns APIGatewayProxyResult for error responses', async () => { + // Prepare + const app = new TestResolver(); + app.get('/test', () => { + throw new Error('test error'); + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + const body = JSON.parse(result?.body ?? '{}'); + expect(body.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + expect(body.error).toBe('Internal Server Error'); + }); }); }); diff --git a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts index f9af1b092a..6ac852b176 100644 --- a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; import { HttpErrorCodes } from '../../../src/rest/constants.js'; import { ErrorHandlerRegistry } from '../../../src/rest/ErrorHandlerRegistry.js'; -import type { HttpStatusCode } from '../../../src/types/rest.js'; +import type { + HttpStatusCode, + RequestOptions, +} from '../../../src/types/rest.js'; const createErrorHandler = - (statusCode: HttpStatusCode, message?: string) => async (error: Error) => ({ + (statusCode: HttpStatusCode, message?: string) => + async (error: Error, _options: RequestOptions) => ({ statusCode, error: error.name, message: message ?? error.message, diff --git a/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts index b49d1c5e5b..2ef46f2a03 100644 --- a/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts @@ -13,8 +13,8 @@ describe('Class: RouteHandlerRegistry', () => { ({ path, resolvePath }) => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler1 = () => 'first'; - const handler2 = () => 'second'; + const handler1 = async () => ({ message: 'first' }); + const handler2 = async () => ({ message: 'second' }); const method = HttpVerbs.GET; // Act @@ -50,7 +50,7 @@ describe('Class: RouteHandlerRegistry', () => { ({ path }) => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); const route = new Route(HttpVerbs.GET, path as Path, handler); @@ -68,7 +68,7 @@ describe('Class: RouteHandlerRegistry', () => { it("doesn't register routes with duplicate parameter names", () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Create a route with duplicate parameter names const invalidPath = '/users/:id/posts/:id'; @@ -85,7 +85,7 @@ describe('Class: RouteHandlerRegistry', () => { it('returns null when no route is found', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/users', handler)); @@ -99,8 +99,8 @@ describe('Class: RouteHandlerRegistry', () => { it('skips dynamic routes with different HTTP methods', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const getHandler = () => 'get'; - const postHandler = () => 'post'; + const getHandler = async () => ({ message: 'get' }); + const postHandler = async () => ({ message: 'post' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/:id', getHandler)); @@ -120,9 +120,9 @@ describe('Class: RouteHandlerRegistry', () => { it('handles routes of different specificity', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const generalHandler = () => 'general'; - const specificHandler = () => 'specific'; - const mostSpecificHandler = () => 'most-specific'; + const generalHandler = async () => ({ message: 'general' }); + const specificHandler = async () => ({ message: 'specific' }); + const mostSpecificHandler = async () => ({ message: 'most-specific' }); // Act registry.register( @@ -144,8 +144,8 @@ describe('Class: RouteHandlerRegistry', () => { it('prioritizes static routes over dynamic routes', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const dynamicHandler = () => 'dynamic'; - const staticHandler = () => 'static'; + const dynamicHandler = async () => ({ message: 'dynamic' }); + const staticHandler = async () => ({ message: 'static' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/:id', dynamicHandler)); @@ -162,8 +162,8 @@ describe('Class: RouteHandlerRegistry', () => { it('prioritizes deeper paths over shallower ones', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const shallowHandler = () => 'shallow'; - const deepHandler = () => 'deep'; + const shallowHandler = async () => ({ message: 'shallow' }); + const deepHandler = async () => ({ message: 'deep' }); // Act registry.register(new Route(HttpVerbs.GET, '/api/:id', shallowHandler)); @@ -178,8 +178,8 @@ describe('Class: RouteHandlerRegistry', () => { it('prioritizes more specific segments over generic parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const genericHandler = () => 'generic'; - const specificHandler = () => 'specific'; + const genericHandler = async () => ({ message: 'generic' }); + const specificHandler = async () => ({ message: 'specific' }); // Act registry.register(new Route(HttpVerbs.GET, '/:a/:b', genericHandler)); @@ -196,8 +196,8 @@ describe('Class: RouteHandlerRegistry', () => { it('prioritizes routes with fewer parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const moreParamsHandler = () => 'more-params'; - const fewerParamsHandler = () => 'fewer-params'; + const moreParamsHandler = async () => ({ message: 'more-params' }); + const fewerParamsHandler = async () => ({ message: 'fewer-params' }); // Act registry.register( @@ -216,8 +216,8 @@ describe('Class: RouteHandlerRegistry', () => { it('prioritizes static segments over parameters when parameter count differs', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const moreParamsHandler = () => 'more-params'; - const staticHandler = () => 'static'; + const moreParamsHandler = async () => ({ message: 'more-params' }); + const staticHandler = async () => ({ message: 'static' }); // Act registry.register( @@ -236,8 +236,8 @@ describe('Class: RouteHandlerRegistry', () => { it('prioritizes more static segments in mixed routes', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const lessStaticHandler = () => 'less-static'; - const moreStaticHandler = () => 'more-static'; + const lessStaticHandler = async () => ({ message: 'less-static' }); + const moreStaticHandler = async () => ({ message: 'more-static' }); // Act registry.register( @@ -256,9 +256,9 @@ describe('Class: RouteHandlerRegistry', () => { it('handles complex mixed static/dynamic precedence', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const allDynamicHandler = () => 'all-dynamic'; - const mixedHandler = () => 'mixed'; - const mostStaticHandler = () => 'most-static'; + const allDynamicHandler = async () => ({ message: 'all-dynamic' }); + const mixedHandler = async () => ({ message: 'mixed' }); + const mostStaticHandler = async () => ({ message: 'most-static' }); // Act registry.register( @@ -280,8 +280,8 @@ describe('Class: RouteHandlerRegistry', () => { it('maintains specificity regardless of registration order - specific first', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const specificHandler = () => 'specific'; - const generalHandler = () => 'general'; + const specificHandler = async () => ({ message: 'specific' }); + const generalHandler = async () => ({ message: 'general' }); // Act - Register specific route first registry.register( @@ -300,8 +300,8 @@ describe('Class: RouteHandlerRegistry', () => { it('maintains specificity regardless of registration order - general first', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const specificHandler = () => 'specific'; - const generalHandler = () => 'general'; + const specificHandler = async () => ({ message: 'specific' }); + const generalHandler = async () => ({ message: 'general' }); // Act registry.register( @@ -320,8 +320,8 @@ describe('Class: RouteHandlerRegistry', () => { it('handles root-level routes', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const rootHandler = () => 'root'; - const paramHandler = () => 'param'; + const rootHandler = async () => ({ message: 'root' }); + const paramHandler = async () => ({ message: 'param' }); // Act registry.register(new Route(HttpVerbs.GET, '/:id', paramHandler)); @@ -340,8 +340,8 @@ describe('Class: RouteHandlerRegistry', () => { it('handles very long paths with mixed segments', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const longGenericHandler = () => 'long-generic'; - const longSpecificHandler = () => 'long-specific'; + const longGenericHandler = async () => ({ message: 'long-generic' }); + const longSpecificHandler = async () => ({ message: 'long-specific' }); // Act registry.register( @@ -373,7 +373,7 @@ describe('Class: RouteHandlerRegistry', () => { it('extracts single parameter correctly', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/:id', handler)); @@ -389,7 +389,7 @@ describe('Class: RouteHandlerRegistry', () => { it('extracts multiple parameters correctly', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register( @@ -407,7 +407,7 @@ describe('Class: RouteHandlerRegistry', () => { it('returns empty params for static routes', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/profile', handler)); @@ -423,7 +423,7 @@ describe('Class: RouteHandlerRegistry', () => { it('decodes URL-encoded spaces in parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/search/:query', handler)); @@ -438,7 +438,7 @@ describe('Class: RouteHandlerRegistry', () => { it('decodes URL-encoded special characters in parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/:email', handler)); @@ -456,7 +456,7 @@ describe('Class: RouteHandlerRegistry', () => { it('decodes multiple URL-encoded parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register( @@ -482,7 +482,7 @@ describe('Class: RouteHandlerRegistry', () => { it('throws ParameterValidationError for whitespace-only parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/:id', handler)); @@ -496,7 +496,7 @@ describe('Class: RouteHandlerRegistry', () => { it('extracts parameters with complex route patterns', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register( @@ -530,7 +530,7 @@ describe('Class: RouteHandlerRegistry', () => { it('handles mixed parameter types and URL encoding', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register( @@ -558,7 +558,7 @@ describe('Class: RouteHandlerRegistry', () => { it('throws ParameterValidationError with correct error message for whitespace-only parameter', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register(new Route(HttpVerbs.GET, '/users/:id', handler)); @@ -572,7 +572,7 @@ describe('Class: RouteHandlerRegistry', () => { it('throws ParameterValidationError with multiple error messages for multiple invalid parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register( @@ -588,7 +588,7 @@ describe('Class: RouteHandlerRegistry', () => { it('includes all validation issues in error message', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const handler = async () => ({ message: 'test' }); // Act registry.register( diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 3c2b2b1b84..130a3dd1a6 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -1,277 +1,437 @@ import type { APIGatewayProxyEvent } from 'aws-lambda'; import { describe, expect, it } from 'vitest'; -import { proxyEventToWebRequest } from '../../../src/rest/converters.js'; - -describe('proxyEventToWebRequest', () => { - const baseEvent: APIGatewayProxyEvent = { - httpMethod: 'GET', - path: '/test', - resource: '/test', - headers: {}, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: {}, - pathParameters: null, - stageVariables: null, - requestContext: { - accountId: '123456789012', - apiId: 'test-api', +import { + handlerResultToProxyResult, + proxyEventToWebRequest, + responseToProxyResult, +} from '../../../src/rest/converters.js'; + +describe('Converters', () => { + describe('proxyEventToWebRequest', () => { + const baseEvent: APIGatewayProxyEvent = { httpMethod: 'GET', path: '/test', - requestId: 'test-request-id', - resourceId: 'test-resource', - resourcePath: '/test', - stage: 'test', - domainName: 'api.example.com', - identity: { - sourceIp: '127.0.0.1', - }, - } as any, - isBase64Encoded: false, - body: null, - }; - - it('should convert basic GET request', () => { - const request = proxyEventToWebRequest(baseEvent); - - expect(request).toBeInstanceOf(Request); - expect(request.method).toBe('GET'); - expect(request.url).toBe('http://api.example.com/test'); - expect(request.body).toBe(null); - }); - - it('should use Host header over domainName', () => { - const event = { - ...baseEvent, - headers: { Host: 'custom.example.com' }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.url).toBe('http://custom.example.com/test'); - }); - - it('should use X-Forwarded-Proto header for protocol', () => { - const event = { - ...baseEvent, - headers: { 'X-Forwarded-Proto': 'https' }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.url).toBe('https://api.example.com/test'); - }); - - it('should handle null values in multiValueHeaders arrays', () => { - const event = { - ...baseEvent, - multiValueHeaders: { - Accept: null as any, - 'Custom-Header': ['value1'], - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.headers.get('Accept')).toBe(null); - expect(request.headers.get('Custom-Header')).toBe('value1'); - }); - - it('should handle null values in multiValueQueryStringParameters arrays', () => { - const event = { - ...baseEvent, - multiValueQueryStringParameters: { - filter: null as any, - sort: ['desc'], - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - const url = new URL(request.url); - expect(url.searchParams.has('filter')).toBe(false); - expect(url.searchParams.get('sort')).toBe('desc'); - }); - - it('should handle POST request with string body', async () => { - const event = { - ...baseEvent, - httpMethod: 'POST', + resource: '/test', + headers: {}, + multiValueHeaders: {}, + queryStringParameters: null, + multiValueQueryStringParameters: {}, + pathParameters: null, + stageVariables: null, requestContext: { - ...baseEvent.requestContext, - httpMethod: 'POST', - }, - body: '{"key":"value"}', - headers: { 'Content-Type': 'application/json' }, + accountId: '123456789012', + apiId: 'test-api', + httpMethod: 'GET', + path: '/test', + requestId: 'test-request-id', + resourceId: 'test-resource', + resourcePath: '/test', + stage: 'test', + domainName: 'api.example.com', + identity: { + sourceIp: '127.0.0.1', + }, + } as any, + isBase64Encoded: false, + body: null, }; - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.method).toBe('POST'); - expect(await request.text()).toBe('{"key":"value"}'); - expect(request.headers.get('Content-Type')).toBe('application/json'); - }); - - it('should decode base64 encoded body', async () => { - const originalText = 'Hello World'; - const base64Text = Buffer.from(originalText).toString('base64'); - - const event = { - ...baseEvent, - httpMethod: 'POST', - requestContext: { - ...baseEvent.requestContext, + it('converts basic GET request', () => { + const request = proxyEventToWebRequest(baseEvent); + + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://api.example.com/test'); + expect(request.body).toBe(null); + }); + + it('uses Host header over domainName', () => { + const event = { + ...baseEvent, + headers: { Host: 'custom.example.com' }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('https://custom.example.com/test'); + }); + + it('uses X-Forwarded-Proto header for protocol', () => { + const event = { + ...baseEvent, + headers: { 'X-Forwarded-Proto': 'https' }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.url).toBe('https://api.example.com/test'); + }); + + it('handles null values in multiValueHeaders arrays', () => { + const event = { + ...baseEvent, + multiValueHeaders: { + Accept: null as any, + 'Custom-Header': ['value1'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Accept')).toBe(null); + expect(request.headers.get('Custom-Header')).toBe('value1'); + }); + + it('handles null values in multiValueQueryStringParameters arrays', () => { + const event = { + ...baseEvent, + multiValueQueryStringParameters: { + filter: null as any, + sort: ['desc'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.has('filter')).toBe(false); + expect(url.searchParams.get('sort')).toBe('desc'); + }); + + it('handles POST request with string body', async () => { + const event = { + ...baseEvent, httpMethod: 'POST', - }, - body: base64Text, - isBase64Encoded: true, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(await request.text()).toBe(originalText); + body: '{"key":"value"}', + headers: { 'Content-Type': 'application/json' }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('POST'); + expect(await request.text()).toBe('{"key":"value"}'); + expect(request.headers.get('Content-Type')).toBe('application/json'); + }); + + it('decodes base64 encoded body', async () => { + const originalText = 'Hello World'; + const base64Text = Buffer.from(originalText).toString('base64'); + + const event = { + ...baseEvent, + httpMethod: 'POST', + body: base64Text, + isBase64Encoded: true, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(await request.text()).toBe(originalText); + }); + + it('handles single-value headers', () => { + const event = { + ...baseEvent, + headers: { + Authorization: 'Bearer token123', + 'User-Agent': 'test-agent', + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Authorization')).toBe('Bearer token123'); + expect(request.headers.get('User-Agent')).toBe('test-agent'); + }); + + it('handles multiValueHeaders', () => { + const event = { + ...baseEvent, + multiValueHeaders: { + Accept: ['application/json', 'text/html'], + 'Custom-Header': ['value1', 'value2'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Accept')).toBe('application/json, text/html'); + expect(request.headers.get('Custom-Header')).toBe('value1, value2'); + }); + + it('handles both single and multi-value headers', () => { + const event = { + ...baseEvent, + headers: { + Authorization: 'Bearer token123', + }, + multiValueHeaders: { + Accept: ['application/json', 'text/html'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Authorization')).toBe('Bearer token123'); + expect(request.headers.get('Accept')).toBe('application/json, text/html'); + }); + + it('deduplicates headers when same header exists in both headers and multiValueHeaders', () => { + const event = { + ...baseEvent, + headers: { + Host: 'abcd1234.execute-api.eu-west-1.amazonaws.com', + 'X-Forwarded-Proto': 'https', + }, + multiValueHeaders: { + Host: ['abcd1234.execute-api.eu-west-1.amazonaws.com'], + 'X-Forwarded-Proto': ['https'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Host')).toBe( + 'abcd1234.execute-api.eu-west-1.amazonaws.com' + ); + expect(request.headers.get('X-Forwarded-Proto')).toBe('https'); + }); + + it('appends unique values from multiValueHeaders when header already exists', () => { + const event = { + ...baseEvent, + headers: { + Accept: 'application/json', + }, + multiValueHeaders: { + Accept: ['application/json', 'text/html'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Accept')).toBe('application/json, text/html'); + }); + + it('handles queryStringParameters', () => { + const event = { + ...baseEvent, + queryStringParameters: { + name: 'john', + age: '25', + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.get('name')).toBe('john'); + expect(url.searchParams.get('age')).toBe('25'); + }); + + it('handles multiValueQueryStringParameters', () => { + const event = { + ...baseEvent, + multiValueQueryStringParameters: { + filter: ['name', 'age'], + sort: ['desc'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.getAll('filter')).toEqual(['name', 'age']); + expect(url.searchParams.get('sort')).toBe('desc'); + }); + + it('handles both queryStringParameters and multiValueQueryStringParameters', () => { + const event = { + ...baseEvent, + queryStringParameters: { + single: 'value', + }, + multiValueQueryStringParameters: { + multi: ['value1', 'value2'], + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.get('single')).toBe('value'); + expect(url.searchParams.getAll('multi')).toEqual(['value1', 'value2']); + }); + + it('skips null queryStringParameter values', () => { + const event = { + ...baseEvent, + queryStringParameters: { + valid: 'value', + null: null as any, + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + const url = new URL(request.url); + expect(url.searchParams.get('valid')).toBe('value'); + expect(url.searchParams.has('null')).toBe(false); + }); + + it('skips null header values', () => { + const event = { + ...baseEvent, + headers: { + 'Valid-Header': 'value', + 'Null-Header': null as any, + }, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.headers.get('Valid-Header')).toBe('value'); + expect(request.headers.get('Null-Header')).toBe(null); + }); + + it('handles null/undefined collections', () => { + const event = { + ...baseEvent, + headers: null as any, + multiValueHeaders: null as any, + queryStringParameters: null as any, + multiValueQueryStringParameters: null as any, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://api.example.com/test'); + }); }); - it('should handle single-value headers', () => { - const event = { - ...baseEvent, - headers: { - Authorization: 'Bearer token123', - 'User-Agent': 'test-agent', - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.headers.get('Authorization')).toBe('Bearer token123'); - expect(request.headers.get('User-Agent')).toBe('test-agent'); + describe('responseToProxyResult', () => { + it('converts basic Response to API Gateway result', async () => { + const response = new Response('Hello World', { + status: 200, + headers: { + 'Content-type': 'application/json', + }, + }); + + const result = await responseToProxyResult(response); + + expect(result.statusCode).toBe(200); + expect(result.body).toBe('Hello World'); + expect(result.isBase64Encoded).toBe(false); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.multiValueHeaders).toEqual({}); + }); + + it('handles single-value headers', async () => { + const response = new Response('Hello', { + status: 201, + headers: { 'content-type': 'text/plain', 'x-custom': 'value' }, + }); + + const result = await responseToProxyResult(response); + + expect(result.statusCode).toBe(201); + expect(result.headers).toEqual({ + 'content-type': 'text/plain', + 'x-custom': 'value', + }); + expect(result.multiValueHeaders).toEqual({}); + }); + + it('handles multi-value headers', async () => { + const response = new Response('Hello', { + status: 200, + headers: { + 'Set-Cookie': 'cookie1=value1, cookie2=value2', + 'Content-type': 'application/json', + }, + }); + + const result = await responseToProxyResult(response); + + expect(result.headers).toEqual({ 'content-type': 'application/json' }); + expect(result.multiValueHeaders).toEqual({ + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }); + }); + + it('handles mixed single and multi-value headers', async () => { + const response = new Response('Hello', { + status: 200, + headers: { + 'content-type': 'application/json', + 'Set-Cookie': 'session=abc, theme=dark', + }, + }); + + const result = await responseToProxyResult(response); + + expect(result.headers).toEqual({ + 'content-type': 'application/json', + }); + expect(result.multiValueHeaders).toEqual({ + 'set-cookie': ['session=abc', 'theme=dark'], + }); + }); + + it('handles different status codes', async () => { + const response = new Response('Not Found', { status: 404 }); + + const result = await responseToProxyResult(response); + + expect(result.statusCode).toBe(404); + expect(result.body).toBe('Not Found'); + }); + + it('handles empty response body', async () => { + const response = new Response(null, { status: 204 }); + + const result = await responseToProxyResult(response); + + expect(result.statusCode).toBe(204); + expect(result.body).toBe(''); + }); }); - it('should handle multiValueHeaders', () => { - const event = { - ...baseEvent, - multiValueHeaders: { - Accept: ['application/json', 'text/html'], - 'Custom-Header': ['value1', 'value2'], - }, - }; + describe('handlerResultToProxyResult', () => { + it('returns APIGatewayProxyResult as-is', async () => { + const proxyResult = { + statusCode: 200, + body: 'test', + headers: { 'content-type': 'text/plain' }, + isBase64Encoded: false, + }; - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.headers.get('Accept')).toBe('application/json, text/html'); - expect(request.headers.get('Custom-Header')).toBe('value1, value2'); - }); + const result = await handlerResultToProxyResult(proxyResult); - it('should handle both single and multi-value headers', () => { - const event = { - ...baseEvent, - headers: { - Authorization: 'Bearer token123', - }, - multiValueHeaders: { - Accept: ['application/json', 'text/html'], - }, - }; + expect(result).toBe(proxyResult); + }); - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.headers.get('Authorization')).toBe('Bearer token123'); - expect(request.headers.get('Accept')).toBe('application/json, text/html'); - }); + it('converts Response object', async () => { + const response = new Response('Hello', { status: 201 }); - it('should handle queryStringParameters', () => { - const event = { - ...baseEvent, - queryStringParameters: { - name: 'john', - age: '25', - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - const url = new URL(request.url); - expect(url.searchParams.get('name')).toBe('john'); - expect(url.searchParams.get('age')).toBe('25'); - }); + const result = await handlerResultToProxyResult(response); - it('should handle multiValueQueryStringParameters', () => { - const event = { - ...baseEvent, - multiValueQueryStringParameters: { - filter: ['name', 'age'], - sort: ['desc'], - }, - }; + expect(result.statusCode).toBe(201); + expect(result.body).toBe('Hello'); + expect(result.isBase64Encoded).toBe(false); + }); - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - const url = new URL(request.url); - expect(url.searchParams.getAll('filter')).toEqual(['name', 'age']); - expect(url.searchParams.get('sort')).toBe('desc'); - }); + it('converts plain object to JSON', async () => { + const obj = { message: 'success', data: [1, 2, 3] }; - it('should handle both queryStringParameters and multiValueQueryStringParameters', () => { - const event = { - ...baseEvent, - queryStringParameters: { - single: 'value', - }, - multiValueQueryStringParameters: { - multi: ['value1', 'value2'], - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - const url = new URL(request.url); - expect(url.searchParams.get('single')).toBe('value'); - expect(url.searchParams.getAll('multi')).toEqual(['value1', 'value2']); - }); - - it('should skip null queryStringParameter values', () => { - const event = { - ...baseEvent, - queryStringParameters: { - valid: 'value', - null: null as any, - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - const url = new URL(request.url); - expect(url.searchParams.get('valid')).toBe('value'); - expect(url.searchParams.has('null')).toBe(false); - }); - - it('should skip null header values', () => { - const event = { - ...baseEvent, - headers: { - 'Valid-Header': 'value', - 'Null-Header': null as any, - }, - }; - - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.headers.get('Valid-Header')).toBe('value'); - expect(request.headers.get('Null-Header')).toBe(null); - }); - - it('should handle null/undefined collections', () => { - const event = { - ...baseEvent, - headers: null as any, - multiValueHeaders: null as any, - queryStringParameters: null as any, - multiValueQueryStringParameters: null as any, - }; + const result = await handlerResultToProxyResult(obj); - const request = proxyEventToWebRequest(event); - expect(request).toBeInstanceOf(Request); - expect(request.method).toBe('GET'); - expect(request.url).toBe('http://api.example.com/test'); + expect(result.statusCode).toBe(200); + expect(result.body).toBe(JSON.stringify(obj)); + expect(result.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(result.isBase64Encoded).toBe(false); + }); }); }); diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index dfe5771f5d..2a2f981f39 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -1,8 +1,9 @@ -import type { APIGatewayProxyEvent } from 'aws-lambda'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { describe, expect, it } from 'vitest'; import { compilePath, isAPIGatewayProxyEvent, + isAPIGatewayProxyResult, validatePathPattern, } from '../../../src/rest/utils.js'; import type { Path } from '../../../src/types/rest.js'; @@ -299,4 +300,67 @@ describe('Path Utilities', () => { expect(isAPIGatewayProxyEvent(incompleteEvent)).toBe(false); }); }); + + describe('isAPIGatewayProxyResult', () => { + it('should return true for valid API Gateway Proxy result', () => { + const validResult: APIGatewayProxyResult = { + statusCode: 200, + body: 'Hello World', + }; + + expect(isAPIGatewayProxyResult(validResult)).toBe(true); + }); + + it('should return true for valid result with all optional fields', () => { + const validResult = { + statusCode: 200, + body: 'Hello World', + headers: { 'Content-Type': 'text/plain' }, + multiValueHeaders: { 'Set-Cookie': ['cookie1', 'cookie2'] }, + isBase64Encoded: false, + }; + + expect(isAPIGatewayProxyResult(validResult)).toBe(true); + }); + + it.each([ + { case: 'null', result: null }, + { case: 'undefined', result: undefined }, + { case: 'string', result: 'not an object' }, + { case: 'number', result: 123 }, + { case: 'array', result: [] }, + ])('should return false for $case', ({ result }) => { + expect(isAPIGatewayProxyResult(result)).toBe(false); + }); + + it.each([ + { field: 'statusCode', value: 'not a number' }, + { field: 'statusCode', value: null }, + { field: 'body', value: 123 }, + { field: 'body', value: null }, + { field: 'headers', value: 'not an object' }, + { field: 'multiValueHeaders', value: 'not an object' }, + { field: 'isBase64Encoded', value: 'not a boolean' }, + ])( + 'should return false when $field is invalid ($value)', + ({ field, value }) => { + const baseResult = { + statusCode: 200, + body: 'Hello World', + }; + + const invalidResult = { ...baseResult, [field]: value }; + expect(isAPIGatewayProxyResult(invalidResult)).toBe(false); + } + ); + + it('should return false when required fields are missing', () => { + const incompleteResult = { + statusCode: 200, + // missing body + }; + + expect(isAPIGatewayProxyResult(incompleteResult)).toBe(false); + }); + }); });