diff --git a/src/Application.ts b/src/Application.ts index cb89ebd..98b01ef 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -1,8 +1,9 @@ -import { Callback } from 'aws-lambda'; +import { Callback, Context } from 'aws-lambda'; import Router from './Router'; import { RequestEvent, HandlerContext } from './request-response-types'; -import { StringUnknownMap } from './utils/common-types'; +import { StringUnknownMap, Writable } from './utils/common-types'; import { Request, Response } from '.'; +import _ from 'underscore'; export default class Application extends Router { @@ -85,8 +86,8 @@ export default class Application extends Router { * @param context The context provided to the Lambda handler * @param cb The callback provided to the Lambda handler */ - public run(evt: RequestEvent, context: HandlerContext, cb: Callback): void { - const req = new Request(this, evt, context), + public run(evt: RequestEvent, context: Context, cb: Callback): void { + const req = new Request(this, evt, this._createHandlerContext(context)), resp = new Response(this, req, cb); this.handle(undefined, req, resp, (err: unknown): void => { @@ -99,4 +100,29 @@ 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); + } + } diff --git a/src/Request.ts b/src/Request.ts index 3a008fe..a8b2a20 100644 --- a/src/Request.ts +++ b/src/Request.ts @@ -246,8 +246,6 @@ export default class Request { this.eventSourceType = ('elb' in event.requestContext) ? Request.SOURCE_ALB : Request.SOURCE_APIGW; - // TODO: should something be done to limit what's exposed by these contexts? For - // example, make properties read-only and don't expose the callback function, etc. this.context = context; this.requestContext = event.requestContext; diff --git a/src/request-response-types.ts b/src/request-response-types.ts index 0b0f2ec..2259c59 100644 --- a/src/request-response-types.ts +++ b/src/request-response-types.ts @@ -30,7 +30,18 @@ export interface ResponseResult extends APIGatewayProxyResult { * The `context` object passed to a Lambda handler. */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface HandlerContext extends Context {} +export interface HandlerContext extends Readonly> {} /* API GATEWAY TYPES (we export these with our own names to make it easier to modify them diff --git a/src/utils/common-types.ts b/src/utils/common-types.ts index 6fde71f..8ac656a 100644 --- a/src/utils/common-types.ts +++ b/src/utils/common-types.ts @@ -4,6 +4,8 @@ export interface StringMap { [s: string]: string } export interface StringUnknownMap { [s: string]: unknown } export interface StringArrayOfStringsMap { [s: string]: string[] } export interface KeyValueStringObject { [k: string]: (string | string[] | KeyValueStringObject) } +// Removes `readonly` modifier and make all keys writable again +export type Writable = { -readonly [P in keyof T]-?: T[P] }; export function isStringMap(o: any): o is StringMap { if (!_.isObject(o) || _.isArray(o)) { // because arrays are objects diff --git a/tests/integration-tests.test.ts b/tests/integration-tests.test.ts index 3208dde..c6e5fd1 100644 --- a/tests/integration-tests.test.ts +++ b/tests/integration-tests.test.ts @@ -547,4 +547,49 @@ describe('integration tests', () => { }); + describe('request object', () => { + + it('has an immutable context property', () => { + let evt = makeRequestEvent('/test', apiGatewayRequest(), 'GET'), + ctx = handlerContext(true), + handler; + + function isPropFrozen(obj: any, key: string): boolean { + try { + obj[key] = 'change'; + return false; + } catch(e) { + if (e instanceof Error) { + return e.message.indexOf('Cannot assign to read only property') !== -1; + } + return false; + } + } + + handler = spy((req: Request, resp: Response) => { + expect(req.context).to.be.an('object'); + + expect(isPropFrozen(req.context, 'awsRequestId')); + expect(isPropFrozen(req.context, 'clientContext')); + + if (req.context.clientContext) { + expect(isPropFrozen(req.context.clientContext, 'clientContext')); + } + + if (req.context.identity) { + expect(isPropFrozen(req.context.identity, 'cognitoIdentityId')); + } + + resp.send('test'); + }); + app.get('*', handler); + + app.run(evt, ctx, spy()); + + // Make sure the handler ran, otherwise the test is invalid. + assert.calledOnce(handler); + }); + + }); + }); diff --git a/tests/samples.ts b/tests/samples.ts index 10e6127..1fde56a 100644 --- a/tests/samples.ts +++ b/tests/samples.ts @@ -3,11 +3,13 @@ import { APIGatewayEventRequestContext, ApplicationLoadBalancerEventRequestContext, APIGatewayRequestEvent, - HandlerContext, ApplicationLoadBalancerRequestEvent } from '../src/request-response-types'; +import { Context } from 'aws-lambda'; -export const handlerContext = (): HandlerContext => { - return { +export const handlerContext = (fillAllFields: boolean = false): Context => { + let ctx: Context; + + ctx = { callbackWaitsForEmptyEventLoop: true, logGroupName: '/aws/lambda/echo-api-prd-echo', logStreamName: '2019/01/31/[$LATEST]bb001267fb004ffa8e1710bba30b4ae7', @@ -21,6 +23,32 @@ export const handlerContext = (): HandlerContext => { fail: () => undefined, succeed: () => undefined, }; + + if (fillAllFields) { + ctx.identity = { + cognitoIdentityId: 'cognitoIdentityId', + cognitoIdentityPoolId: 'cognitoIdentityPoolId', + }; + + ctx.clientContext = { + client: { + installationId: 'installationId', + appTitle: 'appTitle', + appVersionName: 'appVersionName', + appVersionCode: 'appVersionCode', + appPackageName: 'appPackageName', + }, + env: { + platformVersion: 'platformVersion', + platform: 'platform', + make: 'make', + model: 'model', + locale: 'locale', + }, + }; + } + + return ctx; }; export const apiGatewayRequestContext = (): APIGatewayEventRequestContext => {