diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts new file mode 100644 index 0000000000..9c7cdbe825 --- /dev/null +++ b/packages/event-handler/src/rest/converters.ts @@ -0,0 +1,48 @@ +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const createBody = (body: string | null, isBase64Encoded: boolean) => { + if (body === null) return null; + + if (!isBase64Encoded) { + return body; + } + return Buffer.from(body, 'base64').toString('utf8'); +}; + +export const proxyEventToWebRequest = (event: APIGatewayProxyEvent) => { + const { httpMethod, path, domainName } = event.requestContext; + + const headers = new Headers(); + for (const [name, value] of Object.entries(event.headers ?? {})) { + if (value != null) headers.append(name, value); + } + + for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) { + for (const value of values ?? []) { + headers.append(name, value); + } + } + const hostname = headers.get('Host') ?? domainName; + const protocol = headers.get('X-Forwarded-Proto') ?? 'http'; + + const url = new URL(path, `${protocol}://${hostname}/`); + + for (const [name, value] of Object.entries( + event.queryStringParameters ?? {} + )) { + if (value != null) url.searchParams.append(name, value); + } + + for (const [name, values] of Object.entries( + event.multiValueQueryStringParameters ?? {} + )) { + for (const value of values ?? []) { + url.searchParams.append(name, value); + } + } + return new Request(url.toString(), { + method: httpMethod, + headers, + body: createBody(event.body, event.isBase64Encoded), + }); +} diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 58abcc7338..adca15ad66 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -1,3 +1,5 @@ +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'; @@ -43,3 +45,26 @@ export function validatePathPattern(path: Path): ValidationResult { issues, }; } + +/** + * Type guard to check if the provided event is an API Gateway Proxy event. + * + * We use this function to ensure that the event is an object and has the + * required properties without adding a dependency. + * + * @param event - The incoming event to check + */ +export const isAPIGatewayProxyEvent = ( + event: unknown +): event is APIGatewayProxyEvent => { + if (!isRecord(event)) return false; + return ( + isString(event.httpMethod) && + isString(event.path) && + isString(event.resource) && + isRecord(event.headers) && + isRecord(event.requestContext) && + typeof event.isBase64Encoded === 'boolean' && + (event.body === null || isString(event.body)) + ); +}; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 2f37b3577a..fe258fa057 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -53,7 +53,6 @@ interface CompiledRoute { type DynamicRoute = Route & CompiledRoute; -// biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function type RouteHandler< TParams = Record, TReturn = Response | JSONObject, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 97f767e547..a351c02e38 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -3,12 +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, } from '../../../src/rest/errors.js'; +import { isAPIGatewayProxyEvent } from '../../../src/rest/utils.js'; import type { HttpMethod, Path, @@ -43,39 +45,19 @@ describe('Class: BaseRouter', () => { this.logger.error('test error'); } - #isEvent(obj: unknown): asserts obj is APIGatewayProxyEvent { - if ( - typeof obj !== 'object' || - obj === null || - !('path' in obj) || - !('httpMethod' in obj) || - typeof (obj as any).path !== 'string' || - !(obj as any).path.startsWith('/') || - typeof (obj as any).httpMethod !== 'string' || - !Object.values(HttpVerbs).includes( - (obj as any).httpMethod as HttpMethod - ) - ) { - throw new Error('Invalid event object'); - } - } - public async resolve( event: unknown, context: Context, options?: any ): Promise { - this.#isEvent(event); + 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 = new Request(`http://localhost${path}`, { - method, - headers: event.headers as Record, - body: event.body, - }); + const request = proxyEventToWebRequest(event); try { if (route == null) throw new NotFoundError(`Route ${method} ${path} not found`); @@ -838,9 +820,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', () => { diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts new file mode 100644 index 0000000000..3c2b2b1b84 --- /dev/null +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -0,0 +1,277 @@ +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', + 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', + requestContext: { + ...baseEvent.requestContext, + httpMethod: 'POST', + }, + 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('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, + httpMethod: 'POST', + }, + body: base64Text, + isBase64Encoded: true, + }; + + const request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(await request.text()).toBe(originalText); + }); + + 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'); + }); + + it('should handle 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('should handle 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('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'); + }); + + it('should handle 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('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 request = proxyEventToWebRequest(event); + expect(request).toBeInstanceOf(Request); + expect(request.method).toBe('GET'); + expect(request.url).toBe('http://api.example.com/test'); + }); +}); diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index ea31eb68df..dfe5771f5d 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -1,5 +1,10 @@ +import type { APIGatewayProxyEvent } from 'aws-lambda'; import { describe, expect, it } from 'vitest'; -import { compilePath, validatePathPattern } from '../../../src/rest/utils.js'; +import { + compilePath, + isAPIGatewayProxyEvent, + validatePathPattern, +} from '../../../src/rest/utils.js'; import type { Path } from '../../../src/types/rest.js'; describe('Path Utilities', () => { @@ -201,4 +206,97 @@ describe('Path Utilities', () => { } ); }); + + describe('isAPIGatewayProxyEvent', () => { + it('should return true for valid API Gateway Proxy event', () => { + const validEvent: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + resource: '/test', + headers: {}, + requestContext: { + accountId: '123456789012', + apiId: 'test-api', + httpMethod: 'GET', + requestId: 'test-request-id', + resourceId: 'test-resource', + resourcePath: '/test', + stage: 'test', + identity: { + sourceIp: '127.0.0.1', + }, + } as any, + isBase64Encoded: false, + body: null, + } as APIGatewayProxyEvent; + + expect(isAPIGatewayProxyEvent(validEvent)).toBe(true); + }); + + it('should return true for valid event with string body', () => { + const validEvent = { + httpMethod: 'POST', + path: '/test', + resource: '/test', + headers: { 'content-type': 'application/json' }, + requestContext: { stage: 'test' }, + isBase64Encoded: false, + body: '{"key":"value"}', + }; + + expect(isAPIGatewayProxyEvent(validEvent)).toBe(true); + }); + + it.each([ + { case: 'null', event: null }, + { case: 'undefined', event: undefined }, + { case: 'string', event: 'not an object' }, + { case: 'number', event: 123 }, + { case: 'array', event: [] }, + ])('should return false for $case', ({ event }) => { + expect(isAPIGatewayProxyEvent(event)).toBe(false); + }); + + it.each([ + { field: 'httpMethod', value: 123 }, + { field: 'httpMethod', value: null }, + { field: 'path', value: 123 }, + { field: 'path', value: null }, + { field: 'resource', value: 123 }, + { field: 'resource', value: null }, + { field: 'headers', value: 'not an object' }, + { field: 'headers', value: null }, + { field: 'requestContext', value: 'not an object' }, + { field: 'requestContext', value: null }, + { field: 'isBase64Encoded', value: 'not a boolean' }, + { field: 'isBase64Encoded', value: null }, + { field: 'body', value: 123 }, + ])( + 'should return false when $field is invalid ($value)', + ({ field, value }) => { + const baseEvent = { + httpMethod: 'GET', + path: '/test', + resource: '/test', + headers: {}, + requestContext: {}, + isBase64Encoded: false, + body: null, + }; + + const invalidEvent = { ...baseEvent, [field]: value }; + expect(isAPIGatewayProxyEvent(invalidEvent)).toBe(false); + } + ); + + it('should return false when required fields are missing', () => { + const incompleteEvent = { + httpMethod: 'GET', + path: '/test', + // missing resource, headers, requestContext, isBase64Encoded, body + }; + + expect(isAPIGatewayProxyEvent(incompleteEvent)).toBe(false); + }); + }); });