From f77e2d8b595f65a7c9410c6c842b4c1cdc087436 Mon Sep 17 00:00:00 2001 From: svozza Date: Tue, 9 Sep 2025 19:38:52 +0100 Subject: [PATCH] fix(event-handler): handle nullable fields in APIGatewayProxyEvent --- packages/event-handler/src/rest/utils.ts | 11 +- .../tests/unit/rest/utils.test.ts | 160 +++++++++++++----- 2 files changed, 124 insertions(+), 47 deletions(-) diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 8293043877..7c8e897a0a 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -75,10 +75,17 @@ export const isAPIGatewayProxyEvent = ( isString(event.httpMethod) && isString(event.path) && isString(event.resource) && - isRecord(event.headers) && + (event.headers == null || isRecord(event.headers)) && + (event.multiValueHeaders == null || isRecord(event.multiValueHeaders)) && isRecord(event.requestContext) && typeof event.isBase64Encoded === 'boolean' && - (event.body === null || isString(event.body)) + (event.body === null || isString(event.body)) && + (event.pathParameters === null || isRecord(event.pathParameters)) && + (event.queryStringParameters === null || + isRecord(event.queryStringParameters)) && + (event.multiValueQueryStringParameters === null || + isRecord(event.multiValueQueryStringParameters)) && + (event.stageVariables === null || isRecord(event.stageVariables)) ); }; diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index dd9fefa047..d8b6be7229 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -214,45 +214,105 @@ describe('Path Utilities', () => { }); describe('isAPIGatewayProxyEvent', () => { - it('should return true for valid API Gateway Proxy event', () => { - const validEvent: APIGatewayProxyEvent = { - httpMethod: 'GET', + const baseValidEvent = { + httpMethod: 'GET', + path: '/test', + resource: '/test', + headers: {}, + multiValueHeaders: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + pathParameters: {}, + stageVariables: {}, + requestContext: { stage: 'test' }, + isBase64Encoded: false, + body: null, + }; + + it('should return true for valid API Gateway Proxy event with all fields populated', () => { + expect(isAPIGatewayProxyEvent(baseValidEvent)).toBe(true); + }); + + it('should return true for real API Gateway event with null fields', () => { + const realEvent = { + resource: '/{proxy+}', path: '/test', - resource: '/test', - headers: {}, + httpMethod: 'GET', + headers: null, + multiValueHeaders: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: { proxy: 'test' }, + stageVariables: null, requestContext: { - accountId: '123456789012', - apiId: 'test-api', + resourceId: 'ovdb9g', + resourcePath: '/{proxy+}', httpMethod: 'GET', - requestId: 'test-request-id', - resourceId: 'test-resource', - resourcePath: '/test', - stage: 'test', - identity: { - sourceIp: '127.0.0.1', - }, - } as any, - isBase64Encoded: false, + stage: 'test-invoke-stage', + requestId: 'eecdfcfa-225a-4ee3-bdca-05fc31b6018a', + identity: { sourceIp: 'test-invoke-source-ip' }, + }, body: null, - } as APIGatewayProxyEvent; + isBase64Encoded: false, + }; - expect(isAPIGatewayProxyEvent(validEvent)).toBe(true); + expect(isAPIGatewayProxyEvent(realEvent)).toBe(true); }); - it('should return true for valid event with string body', () => { - const validEvent = { + it('should return true for event with string body', () => { + const eventWithBody = { + ...baseValidEvent, httpMethod: 'POST', - path: '/test', - resource: '/test', - headers: { 'content-type': 'application/json' }, - requestContext: { stage: 'test' }, - isBase64Encoded: false, body: '{"key":"value"}', }; - expect(isAPIGatewayProxyEvent(validEvent)).toBe(true); + expect(isAPIGatewayProxyEvent(eventWithBody)).toBe(true); }); + it.each([ + // Headers can be null in reality (even though types say otherwise) + { field: 'headers', value: null }, + { field: 'headers', value: undefined }, + { field: 'multiValueHeaders', value: null }, + { field: 'multiValueHeaders', value: undefined }, + // These are officially nullable in the type definition + { field: 'body', value: null }, + { field: 'pathParameters', value: null }, + { field: 'queryStringParameters', value: null }, + { field: 'multiValueQueryStringParameters', value: null }, + { field: 'stageVariables', value: null }, + ])('should return true when $field is $value', ({ field, value }) => { + const event = { ...baseValidEvent, [field]: value }; + expect(isAPIGatewayProxyEvent(event)).toBe(true); + }); + + it.each([ + { + field: 'headers', + value: { 'content-type': undefined, 'x-api-key': 'test' }, + }, + { + field: 'multiValueHeaders', + value: { accept: undefined, 'x-custom': ['val1', 'val2'] }, + }, + { field: 'pathParameters', value: { id: undefined, name: 'test' } }, + { + field: 'queryStringParameters', + value: { filter: undefined, sort: 'asc' }, + }, + { + field: 'multiValueQueryStringParameters', + value: { tags: undefined, categories: ['a', 'b'] }, + }, + { field: 'stageVariables', value: { env: undefined, version: 'v1' } }, + ])( + 'should return true when $field contains undefined values', + ({ field, value }) => { + const event = { ...baseValidEvent, [field]: value }; + expect(isAPIGatewayProxyEvent(event)).toBe(true); + } + ); + it.each([ { case: 'null', event: null }, { case: 'undefined', event: undefined }, @@ -266,42 +326,52 @@ describe('Path Utilities', () => { it.each([ { field: 'httpMethod', value: 123 }, { field: 'httpMethod', value: null }, + { field: 'httpMethod', value: undefined }, { field: 'path', value: 123 }, { field: 'path', value: null }, + { field: 'path', value: undefined }, { field: 'resource', value: 123 }, { field: 'resource', value: null }, + { field: 'resource', value: undefined }, { field: 'headers', value: 'not an object' }, - { field: 'headers', value: null }, + { field: 'headers', value: 123 }, + { field: 'multiValueHeaders', value: 'not an object' }, + { field: 'multiValueHeaders', value: 123 }, + { field: 'queryStringParameters', value: 'not an object' }, + { field: 'queryStringParameters', value: 123 }, + { field: 'multiValueQueryStringParameters', value: 'not an object' }, + { field: 'multiValueQueryStringParameters', value: 123 }, + { field: 'pathParameters', value: 'not an object' }, + { field: 'pathParameters', value: 123 }, + { field: 'stageVariables', value: 'not an object' }, + { field: 'stageVariables', value: 123 }, { field: 'requestContext', value: 'not an object' }, { field: 'requestContext', value: null }, + { field: 'requestContext', value: undefined }, + { field: 'requestContext', value: 123 }, { field: 'isBase64Encoded', value: 'not a boolean' }, { field: 'isBase64Encoded', value: null }, + { field: 'isBase64Encoded', value: undefined }, + { field: 'isBase64Encoded', value: 123 }, { field: 'body', value: 123 }, + { field: 'body', value: {} }, ])( '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 }; + const invalidEvent = { ...baseValidEvent, [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 - }; - + it.each([ + 'httpMethod', + 'path', + 'resource', + 'requestContext', + 'isBase64Encoded', + ])('should return false when required field %s is missing', (field) => { + const incompleteEvent = { ...baseValidEvent }; + delete incompleteEvent[field as keyof typeof incompleteEvent]; expect(isAPIGatewayProxyEvent(incompleteEvent)).toBe(false); }); });