diff --git a/.changeset/blue-baboons-attend.md b/.changeset/blue-baboons-attend.md new file mode 100644 index 00000000..a1a4c9c3 --- /dev/null +++ b/.changeset/blue-baboons-attend.md @@ -0,0 +1,5 @@ +--- +'@as-integrations/aws-lambda': minor +--- + +ALB Event type integration diff --git a/src/__tests__/integrationALB.test.ts b/src/__tests__/integrationALB.test.ts new file mode 100644 index 00000000..673ca53a --- /dev/null +++ b/src/__tests__/integrationALB.test.ts @@ -0,0 +1,51 @@ +import { ApolloServer, ApolloServerOptions, BaseContext } from '@apollo/server'; +import { + CreateServerForIntegrationTestsOptions, + defineIntegrationTestSuite, +} from '@apollo/server-integration-testsuite'; +import type { ALBEvent, ALBResult, Handler } from 'aws-lambda'; +import { createServer } from 'http'; +import { startServerAndCreateLambdaHandler } from '..'; +import { createMockALBServer } from './mockALBServer'; +import { urlForHttpServer } from './mockServer'; + +describe('lambdaHandlerALB', () => { + defineIntegrationTestSuite( + async function ( + serverOptions: ApolloServerOptions, + testOptions?: CreateServerForIntegrationTestsOptions, + ) { + const httpServer = createServer(); + const server = new ApolloServer({ + ...serverOptions, + }); + + const handler = testOptions + ? startServerAndCreateLambdaHandler(server, testOptions) + : startServerAndCreateLambdaHandler(server); + + httpServer.addListener( + 'request', + createMockALBServer(handler as Handler), + ); + + await new Promise((resolve) => { + httpServer.listen({ port: 0 }, resolve); + }); + + return { + server, + url: urlForHttpServer(httpServer), + async extraCleanup() { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }, + }; + }, + { + serverIsStartedInBackground: true, + noIncrementalDelivery: true, + }, + ); +}); diff --git a/src/__tests__/mockALBServer.ts b/src/__tests__/mockALBServer.ts new file mode 100644 index 00000000..61b6a175 --- /dev/null +++ b/src/__tests__/mockALBServer.ts @@ -0,0 +1,46 @@ +import url from 'url'; +import type { IncomingMessage } from 'http'; +import type { ALBEvent, ALBResult, Handler } from 'aws-lambda'; +import { createMockServer } from './mockServer'; + +export function createMockALBServer(handler: Handler) { + return createMockServer(handler, albEventFromRequest); +} + +function albEventFromRequest(req: IncomingMessage, body: string): ALBEvent { + const urlObject = url.parse(req.url || '', false); + const searchParams = new URLSearchParams(urlObject.search ?? ''); + + const multiValueQueryStringParameters: ALBEvent['multiValueQueryStringParameters'] = + {}; + + for (const [key] of searchParams.entries()) { + const all = searchParams.getAll(key); + if (all.length > 1) { + multiValueQueryStringParameters[key] = all; + } + } + + return { + requestContext: { + elb: { + targetGroupArn: '...', + }, + }, + httpMethod: req.method ?? 'GET', + path: urlObject.pathname ?? '/', + queryStringParameters: Object.fromEntries(searchParams.entries()), + headers: Object.fromEntries( + Object.entries(req.headers).map(([name, value]) => { + if (Array.isArray(value)) { + return [name, value.join(',')]; + } else { + return [name, value]; + } + }), + ), + multiValueQueryStringParameters, + body, + isBase64Encoded: false, + }; +} diff --git a/src/__tests__/mockServer.ts b/src/__tests__/mockServer.ts index cfb0fafc..fac764d7 100644 --- a/src/__tests__/mockServer.ts +++ b/src/__tests__/mockServer.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, Server, ServerResponse } from 'http'; import type { + ALBResult, APIGatewayProxyEvent, + APIGatewayProxyEventV2, APIGatewayProxyResult, APIGatewayProxyStructuredResultV2, Context as LambdaContext, @@ -8,17 +10,19 @@ import type { } from 'aws-lambda'; import { format } from 'url'; import type { AddressInfo } from 'net'; -import type { GatewayEvent } from '..'; +import type { IncomingEvent } from '..'; -type LambdaHandler = Handler< +type LambdaHandler = Handler< T, T extends APIGatewayProxyEvent ? APIGatewayProxyResult - : APIGatewayProxyStructuredResultV2 + : T extends APIGatewayProxyEventV2 + ? APIGatewayProxyStructuredResultV2 + : ALBResult >; // Returns a Node http handler that invokes a Lambda handler (v1 / v2) -export function createMockServer( +export function createMockServer( handler: LambdaHandler, eventFromRequest: (req: IncomingMessage, body: string) => T, ) { diff --git a/src/index.ts b/src/index.ts index 6ab0b35e..8db62881 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,9 +7,9 @@ import type { import { HeaderMap } from '@apollo/server'; import type { WithRequired } from '@apollo/utils.withrequired'; import type { + ALBEvent, + ALBResult, APIGatewayProxyEvent, - APIGatewayProxyEventHeaders, - APIGatewayProxyEventQueryStringParameters, APIGatewayProxyEventV2, APIGatewayProxyResult, APIGatewayProxyStructuredResultV2, @@ -17,10 +17,18 @@ import type { Handler, } from 'aws-lambda'; -export type GatewayEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2; +export type IncomingEvent = + | APIGatewayProxyEvent + | APIGatewayProxyEventV2 + | ALBEvent; + +/** + * @deprecated Use {IncomingEvent} instead + */ +export type GatewayEvent = IncomingEvent; export interface LambdaContextFunctionArgument { - event: GatewayEvent; + event: IncomingEvent; context: Context; } @@ -28,10 +36,12 @@ export interface LambdaHandlerOptions { context?: ContextFunction<[LambdaContextFunctionArgument], TContext>; } -type LambdaHandler = Handler< - GatewayEvent, - APIGatewayProxyStructuredResultV2 | APIGatewayProxyResult ->; +export type HandlerResult = + | APIGatewayProxyStructuredResultV2 + | APIGatewayProxyResult + | ALBResult; + +type LambdaHandler = Handler; export function startServerAndCreateLambdaHandler( server: ApolloServer, @@ -62,7 +72,7 @@ export function startServerAndCreateLambdaHandler( return async function (event, context) { try { - const normalizedEvent = normalizeGatewayEvent(event); + const normalizedEvent = normalizeIncomingEvent(event); const { body, headers, status } = await server.executeHTTPGraphQLRequest({ httpGraphQLRequest: normalizedEvent, @@ -90,63 +100,33 @@ export function startServerAndCreateLambdaHandler( }; } -function normalizeGatewayEvent(event: GatewayEvent): HTTPGraphQLRequest { - if (isV1Event(event)) { - return normalizeV1Event(event); - } - - if (isV2Event(event)) { - return normalizeV2Event(event); +function normalizeIncomingEvent(event: IncomingEvent): HTTPGraphQLRequest { + let httpMethod: string; + if ('httpMethod' in event) { + httpMethod = event.httpMethod; + } else { + httpMethod = event.requestContext.http.method; } - - throw Error('Unknown event type'); -} - -function isV1Event(event: GatewayEvent): event is APIGatewayProxyEvent { - // APIGatewayProxyEvent incorrectly omits `version` even though API Gateway v1 - // events may include `version: "1.0"` - return ( - !('version' in event) || ('version' in event && event.version === '1.0') - ); -} - -function isV2Event(event: GatewayEvent): event is APIGatewayProxyEventV2 { - return 'version' in event && event.version === '2.0'; -} - -function normalizeV1Event(event: APIGatewayProxyEvent): HTTPGraphQLRequest { const headers = normalizeHeaders(event.headers); - const body = parseBody(event.body, headers.get('content-type')); - // Single value parameters can be directly added - const searchParams = new URLSearchParams( - normalizeQueryStringParams(event.queryStringParameters), - ); - // Passing a key with an array entry to the constructor yields - // one value in the querystring with %2C as the array was flattened to a string - // Multi values must be appended individually to get the to-spec output - for (const [key, values] of Object.entries( - event.multiValueQueryStringParameters ?? {}, - )) { - for (const value of values ?? []) { - searchParams.append(key, value); - } + let search: string; + if ('rawQueryString' in event) { + search = event.rawQueryString; + } else if ('queryStringParameters' in event) { + search = normalizeQueryStringParams( + event.queryStringParameters, + event.multiValueQueryStringParameters, + ).toString(); + } else { + throw new Error('Search params not parsable from event'); } - return { - method: event.httpMethod, - headers, - search: searchParams.toString(), - body, - }; -} + const body = event.body ?? ''; -function normalizeV2Event(event: APIGatewayProxyEventV2): HTTPGraphQLRequest { - const headers = normalizeHeaders(event.headers); return { - method: event.requestContext.http.method, + method: httpMethod, headers, - search: event.rawQueryString, - body: parseBody(event.body, headers.get('content-type')), + search, + body: parseBody(body, headers.get('content-type')), }; } @@ -165,20 +145,31 @@ function parseBody( return ''; } -function normalizeHeaders(headers: APIGatewayProxyEventHeaders): HeaderMap { +function normalizeHeaders(headers: IncomingEvent['headers']): HeaderMap { const headerMap = new HeaderMap(); - for (const [key, value] of Object.entries(headers)) { + for (const [key, value] of Object.entries(headers ?? {})) { headerMap.set(key, value ?? ''); } return headerMap; } function normalizeQueryStringParams( - queryStringParams: APIGatewayProxyEventQueryStringParameters | null, -): Record { - const queryStringRecord: Record = {}; + queryStringParams: Record | null | undefined, + multiValueQueryStringParameters: + | Record + | null + | undefined, +): URLSearchParams { + const params = new URLSearchParams(); for (const [key, value] of Object.entries(queryStringParams ?? {})) { - queryStringRecord[key] = value ?? ''; + params.append(key, value ?? ''); + } + for (const [key, value] of Object.entries( + multiValueQueryStringParameters ?? {}, + )) { + for (const v of value ?? []) { + params.append(key, v); + } } - return queryStringRecord; + return params; }