Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: freeze lambda context object (#18) #29

Merged
merged 1 commit into from
Mar 15, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/Application.ts
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down Expand Up @@ -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 => {
Expand All @@ -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>;

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<HandlerContext>);

return Object.freeze(handlerContext);
}

}
2 changes: 0 additions & 2 deletions src/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
13 changes: 12 additions & 1 deletion src/request-response-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<Context,
'functionName'
| 'functionVersion'
| 'invokedFunctionArn'
| 'memoryLimitInMB'
| 'awsRequestId'
| 'logGroupName'
| 'logStreamName'
| 'identity'
| 'clientContext'
| 'getRemainingTimeInMillis'
>> {}


/* API GATEWAY TYPES (we export these with our own names to make it easier to modify them
Expand Down
2 changes: 2 additions & 0 deletions src/utils/common-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = { -readonly [P in keyof T]-?: T[P] };

export function isStringMap(o: any): o is StringMap {
if (!_.isObject(o) || _.isArray(o)) { // because arrays are objects
Expand Down
45 changes: 45 additions & 0 deletions tests/integration-tests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

});

});
34 changes: 31 additions & 3 deletions tests/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 => {
Expand Down