diff --git a/src/Application.ts b/src/Application.ts index ade2153..f00da0c 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -1,7 +1,7 @@ import { Callback, Context } from 'aws-lambda'; import Router from './Router'; import { RequestEvent, HandlerContext } from './request-response-types'; -import { StringUnknownMap, Writable } from '@silvermine/toolbox'; +import { StringUnknownMap, Writable, isUndefined } from '@silvermine/toolbox'; import { Request, Response } from '.'; import _ from 'underscore'; import { isErrorWithStatusCode } from './interfaces'; @@ -102,28 +102,22 @@ export default class Application extends Router { } private _createHandlerContext(context: Context): HandlerContext { - // keys should exist on both `HandlerContext` and `Context` - const keys: (keyof HandlerContext & keyof Context)[] = [ - 'functionName', 'functionVersion', 'invokedFunctionArn', 'memoryLimitInMB', - 'awsRequestId', 'logGroupName', 'logStreamName', 'identity', 'clientContext', - 'getRemainingTimeInMillis', - ]; - - let handlerContext: Writable; - - handlerContext = _.reduce(keys, (memo, key) => { - let contextValue = context[key]; - - if (typeof contextValue === 'object' && contextValue) { - // Freeze sub-objects - memo[key] = Object.freeze(_.extend({}, contextValue)); - } else if (typeof contextValue !== 'undefined') { - memo[key] = contextValue; - } - return memo; - }, {} as Writable); - - return Object.freeze(handlerContext); + const newContext: Writable = { + functionName: context.functionName, + functionVersion: context.functionVersion, + invokedFunctionArn: context.invokedFunctionArn, + memoryLimitInMB: context.memoryLimitInMB, + awsRequestId: context.awsRequestId, + logGroupName: context.logGroupName, + logStreamName: context.logStreamName, + getRemainingTimeInMillis: context.getRemainingTimeInMillis, + }; + if (!isUndefined(context.identity)) { + newContext.identity = Object.freeze({ ...context.identity }); + } + if (!isUndefined(context.clientContext)) { + newContext.clientContext = Object.freeze({ ...context.clientContext }); + } + return Object.freeze(newContext); } - } diff --git a/src/Request.ts b/src/Request.ts index 654d810..4c1c713 100644 --- a/src/Request.ts +++ b/src/Request.ts @@ -3,7 +3,7 @@ import _ from 'underscore'; import qs from 'qs'; import cookie from 'cookie'; import Application from './Application'; -import { RequestEvent, HandlerContext, RequestEventRequestContext, LambdaEventSourceType } from './request-response-types'; +import { RequestEvent, HandlerContext, RequestEventRequestContext, LambdaEventSourceType, APIGatewayRequestEventV2, isAPIGatewayRequestEventV2 } from './request-response-types'; import { StringMap, KeyValueStringObject, StringArrayOfStringsMap, StringUnknownMap } from '@silvermine/toolbox'; import ConsoleLogger from './logging/ConsoleLogger'; @@ -203,7 +203,7 @@ export default class Request { * Events passed to Lambda handlers by API Gateway and Application Load Balancers * contain a "request context", which is available in this property. */ - public readonly requestContext: RequestEventRequestContext; + public readonly requestContext: RequestEventRequestContext | APIGatewayRequestEventV2['requestContext']; /** * Contains the `context` object passed to the Lambda function's handler. Rarely used @@ -254,7 +254,9 @@ export default class Request { } else { event = eventOrRequest; - const parsedQuery = this._parseQuery(event.multiValueQueryStringParameters || {}, event.queryStringParameters || {}); + const parsedQuery = isAPIGatewayRequestEventV2(event) + ? this._parseQuery({}, {}, event.rawQueryString) + : this._parseQuery(event.multiValueQueryStringParameters || {}, event.queryStringParameters || {}, '') // Despite the fact that the Express docs say that the `originalUrl` is `baseUrl // + path`, it's actually always equal to the original URL that initiated the @@ -262,7 +264,7 @@ export default class Request { // `path` is changed too, *but* `originalUrl` stays the same. This would not be // the case if `originalUrl = `baseUrl + path`. See the documentation on the // `url` getter for more details. - url = `${event.path}?${parsedQuery.raw}`; + url = `${isAPIGatewayRequestEventV2(event) ? event.rawPath : event.path}?${parsedQuery.raw}`; originalURL = url; query = parsedQuery.parsed; } @@ -270,8 +272,8 @@ export default class Request { this.app = app; this._event = event; this._headers = this._parseHeaders(event); - this.method = (event.httpMethod || '').toUpperCase(); - this.body = this._parseBody(event.body); + this.method = this._parseMethod(event); + this.body = this._parseBody(event.body || null); this.eventSourceType = ('elb' in event.requestContext) ? Request.SOURCE_ALB : Request.SOURCE_APIGW; @@ -279,7 +281,7 @@ export default class Request { this.requestContext = event.requestContext; // Fields that depend on headers: - this.cookies = this._parseCookies(); + this.cookies = this._parseCookies(event); this.hostname = this._parseHostname(); this.ip = this._parseIP(); this.protocol = this._parseProtocol(); @@ -476,11 +478,20 @@ export default class Request { } private _parseHeaders(evt: RequestEvent): StringArrayOfStringsMap { - const headers = evt.multiValueHeaders || _.mapObject(evt.headers, (v) => { return [ v ]; }); + let headers; + if (isAPIGatewayRequestEventV2(evt)) { + // NOTE - APIGWv2 multi-value headers that contain commas in their values will + // not be reconstructed as accurately + headers = _.mapObject(evt.headers, (v) => { return (v || '').split(','); }); + } else { + headers = evt.multiValueHeaders || _.mapObject(evt.headers, (v) => { return [ v ]; }); + } return _.reduce(headers, (memo: StringArrayOfStringsMap, v, k) => { const key = k.toLowerCase(); + // evt.multiValueHeaders is a map that can contain undefined values + v ||= []; memo[key] = v; if (key === 'referer') { @@ -493,8 +504,11 @@ export default class Request { }, {}); } - private _parseCookies(): StringUnknownMap { - const cookieHeader = this.get('cookie') || ''; + private _parseCookies(evt: RequestEvent): StringUnknownMap { + // TODO - is option 1 safe? If so, then it is the simpler approach + // Option 1. join evt.cookies with semicolons + // Option 2. reduce the list, parseing and merging into cookies + const cookieHeader = isAPIGatewayRequestEventV2(evt) ? (evt.cookies || []).join(';'): this.get('cookie') || ''; if (_.isEmpty(cookieHeader)) { return {}; @@ -533,15 +547,27 @@ export default class Request { return this.requestContext.identity.sourceIp; } + if ('http' in this.requestContext && !_.isEmpty(this.requestContext.http.sourceIp)) { + return this.requestContext.http.sourceIp; + } + if (!this.app.isEnabled('trust proxy')) { return; } - let ip = (this.get('x-forwarded-for') || '').replace(/,.*/, ''); + // Since the X-Forwarded-For header may or may not have been split on commas, + // get the first element of the list, and then strip anything after the first comma. + let ip = (this.headerAll('x-forwarded-for') || [ '' ])[0].replace(/,.*/, '').trim(); return _.isEmpty(ip) ? undefined : ip; } + private _parseMethod(evt: RequestEvent): string { + return ( + isAPIGatewayRequestEventV2(evt) ? evt.requestContext.http.method : evt.httpMethod + ).toUpperCase(); + } + private _parseProtocol(): string | undefined { if (this.isAPIGW()) { return 'https'; @@ -554,9 +580,7 @@ export default class Request { } } - private _parseQuery(multiValQuery: StringArrayOfStringsMap, query: StringMap): { raw: string; parsed: KeyValueStringObject } { - let queryString; - + private _parseQuery(multiValQuery: Record, query: Record, queryString: string | undefined): { raw: string; parsed: KeyValueStringObject } { // It may seem strange to encode the URI components immediately after decoding them. // But, this allows us to take values that are encoded and those that are not, then // decode them to make sure we know they're not encoded, and then encode them so @@ -564,17 +588,19 @@ export default class Request { // If we simply encoded them, and we received a value that was still encoded // already, then we would encode the `%` signs, etc, and end up with double-encoded // values that were not correct. - if (_.isEmpty(multiValQuery)) { - queryString = _.reduce(query, (memo, v, k) => { - return memo + `&${k}=${encodeURIComponent(safeDecode(v))}`; - }, ''); - } else { - queryString = _.reduce(multiValQuery, (memo, vals, k) => { - _.each(vals, (v) => { - memo += `&${k}=${encodeURIComponent(safeDecode(v))}`; - }); - return memo; - }, ''); + if (!queryString) { + if (_.isEmpty(multiValQuery)) { + queryString = _.reduce(query, (memo, v, k) => { + return memo + `&${k}=${encodeURIComponent(safeDecode(v || ''))}`; + }, ''); + } else { + queryString = _.reduce(multiValQuery, (memo, vals, k) => { + _.each(vals || [], (v) => { + memo += `&${k}=${encodeURIComponent(safeDecode(v || ''))}`; + }); + return memo; + }, ''); + } } return { raw: queryString, parsed: qs.parse(queryString) }; diff --git a/src/Response.ts b/src/Response.ts index 627747e..ddb34ee 100644 --- a/src/Response.ts +++ b/src/Response.ts @@ -2,7 +2,7 @@ import _ from 'underscore'; import cookie from 'cookie'; import { Application, Request } from '.'; import { StringMap, isStringMap, StringArrayOfStringsMap } from '@silvermine/toolbox'; -import { CookieOpts, ResponseResult } from './request-response-types'; +import { CookieOpts, ResponseResult, isALBResult } from './request-response-types'; import { StatusCodes } from './status-codes'; import { Callback } from 'aws-lambda'; import mimeLookup from './mime/mimeLookup'; @@ -411,7 +411,7 @@ export default class Response { body: this._body, }; - if (this.isALB()) { + if (isALBResult(output, this.isALB())) { // There are some differences in the response format between APIGW and ALB. See // https://serverless-training.com/articles/api-gateway-vs-application-load-balancer-technical-details/#application-load-balancer-response-event-format-differences @@ -432,11 +432,12 @@ export default class Response { // because it's the safest thing to do. Note that even if you have no headers // to send, you must at least supply an empty object (`{}`) for ELB, whereas // with APIGW it's okay to send `null`. - output.headers = _.reduce(output.multiValueHeaders, (memo, v, k) => { + output.headers = _.reduce(output.multiValueHeaders || {}, (memo, v, k) => { memo[k] = v[v.length - 1]; return memo; - }, {} as StringMap); + }, {} as Record); + // TODO - the following comment seems to conflict with the new data type // Finally, note that ELB requires that all header values be strings already, // whereas APIGW will allow booleans / integers as values, which it would then // convert. As long as you're using this library from a TypeScript project, the diff --git a/src/request-response-types.ts b/src/request-response-types.ts index ebf50ea..24e396d 100644 --- a/src/request-response-types.ts +++ b/src/request-response-types.ts @@ -2,11 +2,16 @@ import { APIGatewayEventRequestContext as OrigAPIGatewayEventRequestContext, + APIGatewayEventRequestContextV2 as OrigAPIGatewayEventRequestContextV2, APIGatewayProxyEvent, + APIGatewayProxyEventV2, Context, APIGatewayProxyResult, + APIGatewayProxyStructuredResultV2, + ALBEvent, + ALBEventRequestContext, + ALBResult, } from 'aws-lambda'; -import { StringMap, StringArrayOfStringsMap } from '@silvermine/toolbox'; /* COMBO TYPES */ @@ -14,16 +19,18 @@ import { StringMap, StringArrayOfStringsMap } from '@silvermine/toolbox'; * The `evt` argument passed to a Lambda handler that represents the request (from API * Gateway or ALB). */ -export type RequestEvent = ApplicationLoadBalancerRequestEvent | APIGatewayRequestEvent; +export type RequestEvent = ApplicationLoadBalancerRequestEvent | APIGatewayRequestEvent | APIGatewayRequestEventV2; /** * The "request context", which is accessible at `evt.requestContext`. */ export type RequestEventRequestContext = APIGatewayEventRequestContext | ApplicationLoadBalancerEventRequestContext; -export interface ResponseResult extends APIGatewayProxyResult { - multiValueHeaders: StringArrayOfStringsMap; - statusDescription?: string; +export type ResponseResult = APIGatewayProxyResult | APIGatewayProxyStructuredResultV2 | ALBResult; + +export function isALBResult(evt: ResponseResult, test: boolean): evt is ALBResult { + // TODO - this type gaurd doesn't do any useful checking + return test && 'statusCode' in evt; } /** @@ -51,29 +58,23 @@ if needed at a later time) */ export interface APIGatewayRequestEvent extends APIGatewayProxyEvent {} // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface APIGatewayEventRequestContext extends OrigAPIGatewayEventRequestContext {} +export interface APIGatewayRequestEventV2 extends APIGatewayProxyEventV2 {} +export function isAPIGatewayRequestEventV2(evt: RequestEvent): evt is APIGatewayRequestEventV2 { + return ('apiId' in evt.requestContext && 'version' in evt && evt.version === '2.0'); +} -/* APPLICATION LOAD BALANCER TYPES (these are not yet included in aws-lambda) */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface APIGatewayEventRequestContext extends OrigAPIGatewayEventRequestContext {} -export interface ApplicationLoadBalancerRequestEvent { - body: string | null; - httpMethod: string; - isBase64Encoded: boolean; - path: string; - headers?: StringMap; - multiValueHeaders?: StringArrayOfStringsMap; - queryStringParameters?: StringMap; - multiValueQueryStringParameters?: StringArrayOfStringsMap; - requestContext: ApplicationLoadBalancerEventRequestContext; -} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface APIGatewayEventRequestContextV2 extends OrigAPIGatewayEventRequestContextV2 {} -export interface ApplicationLoadBalancerEventRequestContext { - elb: { - targetGroupArn: string; - }; -} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ApplicationLoadBalancerRequestEvent extends ALBEvent {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ApplicationLoadBalancerEventRequestContext extends ALBEventRequestContext {} /* OTHER TYPES RELATED TO REQUESTS AND RESPONSES */ export interface CookieOpts { diff --git a/tests/Request.test.ts b/tests/Request.test.ts index 69ee288..12b84fb 100644 --- a/tests/Request.test.ts +++ b/tests/Request.test.ts @@ -1,7 +1,7 @@ import _ from 'underscore'; import { expect } from 'chai'; import { Request, Application } from '../src'; -import { RequestEvent } from '../src/request-response-types'; +import { RequestEvent, isAPIGatewayRequestEventV2 } from '../src/request-response-types'; import { apiGatewayRequest, handlerContext, @@ -9,6 +9,8 @@ import { albMultiValHeadersRequest, albRequestRawQuery, apiGatewayRequestRawQuery, + apiGatewayRequestV2, + apiGatewayRequestRawQueryV2, albMultiValHeadersRawQuery, } from './samples'; import { isKeyValueStringObject } from '@silvermine/toolbox'; @@ -21,14 +23,16 @@ describe('Request', () => { beforeEach(() => { app = new Application(); - allEventTypes = [ apiGatewayRequest(), albRequest(), albMultiValHeadersRequest() ]; + allEventTypes = [ apiGatewayRequest(), apiGatewayRequestV2(), albRequest(), albMultiValHeadersRequest() ]; allRequestTypes = [ new Request(app, apiGatewayRequest(), handlerContext()), + new Request(app, apiGatewayRequestV2(), handlerContext()), new Request(app, albRequest(), handlerContext()), new Request(app, albMultiValHeadersRequest(), handlerContext()), ]; rawQueries = [ apiGatewayRequestRawQuery, + apiGatewayRequestRawQueryV2, albRequestRawQuery, albMultiValHeadersRawQuery, ]; @@ -45,12 +49,13 @@ describe('Request', () => { expect(new Request(app, _.extend({}, albRequest(), { httpMethod: 'get' }), handlerContext()).method).to.strictlyEqual('GET'); expect(new Request(app, _.extend({}, albRequest(), { httpMethod: 'PoSt' }), handlerContext()).method).to.strictlyEqual('POST'); - // make sure that undefined values don't break it: - let evt2: RequestEvent = albRequest(); + // TODO - this doesn't seem possible without encountering: error TS2790: The operand of a 'delete' operator must be optional. + // // make sure that undefined values don't break it: + // let evt2: RequestEvent = albRequest(); - delete evt2.httpMethod; - expect(evt2.httpMethod).to.strictlyEqual(undefined); - expect(new Request(app, evt2, handlerContext()).method).to.strictlyEqual(''); + // delete evt2.httpMethod; + // expect(evt2.httpMethod).to.strictlyEqual(undefined); + // expect(new Request(app, evt2, handlerContext()).method).to.strictlyEqual(''); }); it('sets URL related fields correctly, when created from an event', () => { @@ -159,7 +164,9 @@ describe('Request', () => { it('works if no headers exist in the event', () => { _.each(allEventTypes, (evt) => { delete evt.headers; - delete evt.multiValueHeaders; + if (!isAPIGatewayRequestEventV2(evt)) { + delete evt.multiValueHeaders; + } const req = new Request(app, evt, handlerContext()); expect(req.header('foo')).to.eql(undefined); @@ -456,7 +463,7 @@ describe('Request', () => { if (evt.headers) { evt.headers['x-requested-with'] = 'XMLHttpRequest'; } - if (evt.multiValueHeaders) { + if (!isAPIGatewayRequestEventV2(evt) && evt.multiValueHeaders) { evt.multiValueHeaders['x-requested-with'] = [ 'XMLHttpRequest' ]; } req = new Request(app, evt, handlerContext()); @@ -510,7 +517,14 @@ describe('Request', () => { const partiallyEncoded = { a: 'b+c', d: 'e%20f', g: 'h i' }, expected = { a: 'b c', d: 'e f', g: 'h i' }; - event.multiValueQueryStringParameters = {}; + if (isAPIGatewayRequestEventV2(event)) { + event.rawQueryString = _.reduce(partiallyEncoded, (memo, v, k) => { + return memo = `${memo}&${k}=${v}`; + }, ''); + } + else { + event.multiValueQueryStringParameters = {}; + } event.queryStringParameters = partiallyEncoded; const req = new Request(app, event, handlerContext()); @@ -524,7 +538,15 @@ describe('Request', () => { const partiallyEncoded = { a: [ 'b+c', 'e%20f', 'h i' ] }, expected = { a: [ 'b c', 'e f', 'h i' ] }; - event.multiValueQueryStringParameters = partiallyEncoded; + if (isAPIGatewayRequestEventV2(event)) { + event.rawQueryString = _.reduce(partiallyEncoded, (memo, mv, k) => { + const paramString = mv.map((v) => `${k}=${v}`).join('&') + return memo = `${memo}&${paramString}`; + }, ''); + } + else { + event.multiValueQueryStringParameters = partiallyEncoded; + } event.queryStringParameters = {}; const req = new Request(app, event, handlerContext()); diff --git a/tests/Response.test.ts b/tests/Response.test.ts index 5f25b67..d455c39 100644 --- a/tests/Response.test.ts +++ b/tests/Response.test.ts @@ -768,7 +768,7 @@ describe('Response', () => { if (overrides.queryParamName !== false) { let queryParamName = overrides.queryParamName || 'callback'; - if (evt.multiValueQueryStringParameters) { + if ('multiValueQueryStringParameters' in evt && evt.multiValueQueryStringParameters) { evt.multiValueQueryStringParameters[queryParamName] = queryParamValues; } if (evt.queryStringParameters) { diff --git a/tests/integration-tests.test.ts b/tests/integration-tests.test.ts index bdc18ba..7ca80fb 100644 --- a/tests/integration-tests.test.ts +++ b/tests/integration-tests.test.ts @@ -8,7 +8,7 @@ import { } from './samples'; import { spy, SinonSpy, assert } from 'sinon'; import { Application, Request, Response, Router } from '../src'; -import { RequestEvent } from '../src/request-response-types'; +import { RequestEvent, ResponseResult } from '../src/request-response-types'; import { NextCallback, IRoute, IRouter, ErrorWithStatusCode } from '../src/interfaces'; import { expect } from 'chai'; import { StringArrayOfStringsMap, StringMap, KeyValueStringObject } from '@silvermine/toolbox'; @@ -369,7 +369,7 @@ describe('integration tests', () => { app.run(evt, handlerContext(), cb); - const expectedCallbackValue = { + const expectedCallbackValue: ResponseResult = { statusCode: code, statusDescription: desc, body: expectedBody, @@ -382,12 +382,12 @@ describe('integration tests', () => { } as StringArrayOfStringsMap, }; - expectedCallbackValue.headers[hdrName] = hdrVal; - expectedCallbackValue.multiValueHeaders[hdrName] = [ hdrVal ]; + expectedCallbackValue.headers![hdrName] = hdrVal; + expectedCallbackValue.multiValueHeaders![hdrName] = [ hdrVal ]; if (contentType) { - expectedCallbackValue.headers['Content-Type'] = contentType; - expectedCallbackValue.multiValueHeaders['Content-Type'] = [ contentType ]; + expectedCallbackValue.headers!['Content-Type'] = contentType; + expectedCallbackValue.multiValueHeaders!['Content-Type'] = [ contentType ]; } if (eventTypeName === 'APIGW') { diff --git a/tests/samples.ts b/tests/samples.ts index 4c18cf6..b7440f6 100644 --- a/tests/samples.ts +++ b/tests/samples.ts @@ -1,8 +1,10 @@ import _ from 'underscore'; import { APIGatewayEventRequestContext, + APIGatewayEventRequestContextV2, ApplicationLoadBalancerEventRequestContext, APIGatewayRequestEvent, + APIGatewayRequestEventV2, ApplicationLoadBalancerRequestEvent } from '../src/request-response-types'; import { Context } from 'aws-lambda'; @@ -14,7 +16,7 @@ export const handlerContext = (fillAllFields: boolean = false): Context => { logGroupName: '/aws/lambda/echo-api-prd-echo', logStreamName: '2019/01/31/[$LATEST]bb001267fb004ffa8e1710bba30b4ae7', functionName: 'echo-api-prd-echo', - memoryLimitInMB: 1024, + memoryLimitInMB: '1024', functionVersion: '$LATEST', awsRequestId: 'ed6cac60-bb31-4c1f-840d-dd34c80eb9a3', invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:echo-api-prd-echo', @@ -67,11 +69,14 @@ export const apiGatewayRequestContext = (): APIGatewayEventRequestContext => { cognitoAuthenticationType: null, cognitoIdentityId: null, cognitoIdentityPoolId: null, + clientCert: null, + principalOrgId: null, sourceIp: '12.12.12.12', user: null, userAgent: 'curl/7.54.0', userArn: null, }, + protocol: 'https', path: '/prd', stage: 'prd', requestId: 'a507736b-259e-11e9-8fcf-4f1f08c4591e', @@ -145,6 +150,87 @@ export const apiGatewayRequest = (): APIGatewayRequestEvent => { }; }; +export const apiGatewayRequestContextV2 = (): APIGatewayEventRequestContextV2 => { + return { + accountId: '123456789012', + apiId: 'someapi', + authentication: { + clientCert: { + clientCertPem: 'CERT_CONTENT', + subjectDN: 'b5gee6dacf.execute-api.us-east-1.amazonaws.com', + issuerDN: 'Example issuer', + serialNumber: 'a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1', + validity: { + notBefore: 'May 28 12:30:02 2019 GMT', + notAfter: 'Aug 5 09:36:04 2025 GM', + } + }, + }, + domainName: 'b5gee6dacf.execute-api.us-east-1.amazonaws.com', + domainPrefix: 'b5gee6dacf', + http: { + method: 'GET', + sourceIp: '12.12.12.12', + protocol: 'HTTP/1.1', + path: '/prd', + userAgent: 'curl/7.54.0', + }, + requestId: 'a507736b-259e-11e9-8fcf-4f1f08c4591e', + routeKey: '$default', + stage: 'prd', + time: '12/Mar/2020:19:03:58 +0000', + timeEpoch: 1548969891530, + }; +}; + +export const apiGatewayRequestRawQueryV2 = '?&foo[a]=bar%20b&foo[a]=baz%20c&x=1&x=2&y=z'; + +export const apiGatewayRequestV2 = (): APIGatewayRequestEventV2 => { + return { + version: '2.0', + routeKey: '$default', + body: '', + isBase64Encoded: false, + rawPath: '/echo/asdf/a', + rawQueryString: '&foo[a]=bar%20b&foo[a]=baz%20c&x=1&x=2&y=z', + stageVariables: {}, + requestContext: apiGatewayRequestContextV2(), + headers: { + Accept: '*/*', + 'CloudFront-Forwarded-Proto': 'https', + 'CloudFront-Is-Desktop-Viewer': 'true', + 'CloudFront-Is-Mobile-Viewer': 'false', + 'CloudFront-Is-SmartTV-Viewer': 'false', + 'CloudFront-Is-Tablet-Viewer': 'false', + 'CloudFront-Viewer-Country': 'US', + Host: 'b5gee6dacf.execute-api.us-east-1.amazonaws.com', + 'User-Agent': 'curl/7.54.0', + Via: '2.0 4ee511e558a0400aa4b9c1d34d92af5a.cloudfront.net (CloudFront)', + 'X-Amz-Cf-Id': 'xn-ohXlUAed-32bae2cfb7164fd690ffffb87d36b032==', + 'X-Amzn-Trace-Id': 'Root=1-4b5398e2-a7fbe4f92f2e911013cba76b', + 'X-Forwarded-For': '8.8.8.8, 2.3.4.5', + 'X-Forwarded-Port': '443', + 'X-Forwarded-Proto': 'https', + Referer: 'https://en.wikipedia.org/wiki/HTTP_referer', + Cookie: 'uid=abc; ga=1234; foo=bar; baz=foo%5Ba%5D; obj=j%3A%7B%22abc%22%3A123%7D; onechar=j; bad=j%3A%7Ba%7D', + }, + cookies: [ + 'uid=abc', + 'ga=1234', + 'foo=bar', + 'baz=foo%5Ba%5D', + 'obj=j%3A%7B%22abc%22%3A123%7D', + 'onechar=j', + 'bad=j%3A%7Ba%7D', + ], + queryStringParameters: { + 'foo[a]': 'bar b,baz c', + x: '1,2', + y: 'z', + }, + }; +}; + export const albRequestContext = (): ApplicationLoadBalancerEventRequestContext => { return { elb: {