src/index.js
import 'source-map-support/register';
import Promise from 'bluebird';
/** @external {Promise} http://bluebirdjs.com/docs/api-reference.html */
/** @external {AWSLambdaContext} http://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html */
/**
* An endpoint is a prototype method in a {@link Handler} subclass. It's so-called
* because it is the end destination to which an event is dispatched. In the
* current version, an endpoint is simply a function with attached {@link EndpointMetadata}.
* In future versions, this may not be the case.
* @typedef {Function} Endpoint
* @property {EndpointMetadata} _λ6_metadata - attached handler metadata
* @experimental Future versions may allow a more abstract model for endpoints.
* Interacting directly with and endpoint or its metadata may cause breaking
* changes.
* @since 2.0.0
* @example
* @operation
* testEndpoint() { return 'testEndpoint is an endpoint'; }
*/
function isUndefinedOrNull(val) {
return val == null; //eslint-disable-line no-eq-null, eqeqeq
}
function checkType(obj, name, types) {
if (isUndefinedOrNull(obj)) {
throw new TypeError(`invalid type for ${name}, cannot be ${obj}`);
}
/* istanbul ignore next */
let theType = typeof obj;
// Currently only functions are allowed as endpoints
if (types.indexOf(theType) < 0) { //eslint-disable-line no-magic-numbers
// ^^ AWS doesn't support Array.prototype.includes()
throw new TypeError(`invalid type for ${name}, cannot be ${theType}`);
}
}
function deepCopy(obj, tpl) {
if (obj != null) { //eslint-disable-line no-eq-null,eqeqeq
// Use typeof here instead of instanceof so that functions get copied over
if (typeof obj === 'object') {
return Object.create(null, createCopyDescriptors(obj, true, tpl)); //eslint-disable-line no-use-before-define
}
}
return obj;
}
/**
* Creates a dictionary of property descriptors that can be used as the second
* argument to {@link Object.defineProperties}. It does this by enumerating
* the own properties of `obj` using {@link Object.keys} and then constructing
* an object with corresponding keys and property descriptors as values of those
* keys. The property descriptor is created using `template` as starting point, to
* which it adds a `value` property. This method can also make deep copies of
* `obj` by passing in a truthy `deep` value. This method doesn't type check `obj`.
* @param {Object} object - the object to create copy descriptors for
* @param {boolean} [deep] - controls whether the `value` property of the property
* @param {Object} [template] - a property descriptor template, to which `value` is added
* descriptor should be populated with a deeply-copied value from the source object.
* @return {Object} that contains property descriptors to use in {@link Object.create}
*/
function createCopyDescriptors(obj, deep, template) {
const pdc = {};
const _tpl = template || { enumerable: true };
Object.keys(obj).forEach(key => {
const val = obj[key];
const valueDesc = { value: { value: deep ? deepCopy(val, _tpl) : val } };
pdc[key] = Object.create(_tpl, valueDesc);
});
return pdc;
}
/**
* Endpoint metadata is what {@link Handler} uses to inspect an an {@link Endpoint}
* to determine if it's eligible to handle a given operation. Operations are
* "whitelisted" so that properties (own or inherited) of the handler don't get
* exposed as operation endpoints where they aren't meant to.
* @typedef {Object} EndpointMetadata
* @since 2.0.0
* @experimental The properties of this are likely to change.
*/
const _defaultMetadata = {};
/**
* Base class for AWS Lambda handlers. This class should be extended and new
* operations should be added to the class as prototype methods. A method added
* to the subclass needs to be registered with the {@link operation} decorator in
* order to be visible as an operation {@link Endpoint}. {@link Handler} will handle
* an event in the following way:
* <ol>
* <li>Extract `operation` and `payload` from the event using keys defined in {@link HandlerOptions}</li>
* <li>Lookup property `this[operation]` in handler and validate it as an {@link Endpoint}</li>
* <li>Create a new {@link InvocationContext} with the current handler as the prototype</li>
* <li>Invoke the {@link Endpoint}, binding `this` to the {@link InvocationContext}</li>
* <li>Call `context.succeed()` or `context.fail()` if an {@link AWSLambdaContext} is present</li>
* <li>Resolve or reject the results (or error) in the promise returned to the caller</li>
* </ol>
* @see http://docs.aws.amazon.com/lambda/latest/dg/programming-model.html
* @version 2.0.0
* @since 1.0.0
* @example
*
* import { Handler, operation } from 'lambda6';
*
* class MyHandler extends Handler {
*
* @operation
* echoOperationName() {
* return { operationName: this.operation };
* }
*
* @operation
* echoValuesFromPayload({ value1, value2 }) {
* return { oldValue1: value1, oldValue2: value2 };
* }
*
* }
*/
export class Handler {
/**
* Gets the key used to access the {@link EndpointMetadata} for an {@link Endpoint}.
* The current value is "_λ6_metadata" and most likely won't conflict with any
* existing properties of the function. AWS Lambda does not yet support {@link Symbol},
* so the metadata key is currently a string.
* @type {string}
* @since 2.0.0
*/
static get metadataKey() {
// String for now, will be a Symbol later
return '_λ6_metadata';
}
/**
* Gets the default options for a new {@link Handler} instance. The defaults
* are "operation" and "payloadKey" for
* @type {HandlerOptions}
* @since 2.0.0
*/
static get defaultOptions() {
return {
operationKey: 'operation',
payloadKey: 'payload'
};
}
/**
* Creates a new instance of the base handler class. Subclasses don't need to
* override this if they wish to store custom data. Simply pass in an `options`
* value and the data will be available as `this.options[key]` during method
* invocation.
* @param {HandlerOptions} [options] - hash of options to adjust the behavior
* of the handler
* @since 2.0.0
*/
constructor(options) {
/**
* The {@link Handler} can be customized to inspect different values for the
* operation and payload within the event. Currently, deep gets are not supported
* for key values, so the data must reside as a direct child of the root JSON
* object (event).
* @typedef {Object} HandlerOptions
* @property {string} [operationKey] - the key used to lookup the `operation`
* from the `event` object.
* @property {string} [payloadKey] - the key used to lookup the `payload`
* from the `event` object.
* @since 2.0.0
* @todo Add support deep gets for operation and payload keys.
*/
this.options = Object.assign({}, Handler.defaultOptions, options);
}
/**
* Validates whether an endpoint object is valid and can be decorated with the
* "@operation" decorator. If it is invalid, it will throw a `TypeError`.
* @param {Endpoint} endpoint - the endpoint to validate
* @throws {TypeError} if the endpoint is not of the correct type
* @private
* @since 2.0.0
*/
static validateEndpoint(endpoint) {
checkType(endpoint, 'endpoint', ['function']);
}
/**
* Gets the {@link EndpointMetadata} from an {@link Endpoint}.
* @param {Endpoint} endpoint - the endpoint to check
* @return {EndpointMetadata} the metadata attached to the given endpoint, or `undefined`
* @throws {TypeError} if the metadata is of the wrong type (not an object), including `null`
* @since 2.0.0
*/
static getEndpointMetadata(endpoint) {
Handler.validateEndpoint(endpoint);
if (!endpoint.hasOwnProperty(Handler.metadataKey)) {
return;
}
const metadata = endpoint[Handler.metadataKey];
checkType(metadata, 'endpoint metadata', ['object']);
return metadata;
}
/**
* Handler method that is exported to AWS Lambda.
* @param {Object} event - the AWS Lambda event to be processed
* @param {AWSLambdaContext} [context] - the AWS Lambda context, optional if testing
* @param {...args} [args] - additional arguments that will get passed to the
* endpoint method when it is invoked.
* @returns {Promise} that resolves to the return value of the invoked endpoint
* or rejects with an error
* @since 2.0.0
*/
handle(event, context, ...args) {
// Validate event
if (isUndefinedOrNull(event)) {
return Promise.reject(new TypeError(`event is required`));
}
function callContextFn(name, value) {
if (context && context[name] instanceof Function) {
context[name](value);
}
}
function onSuccess(result) {
callContextFn('succeed', result);
return result;
}
function onFailure(error) {
callContextFn('fail', error);
throw error;
}
// Extract from event
const {
[this.options.operationKey]: operation,
[this.options.payloadKey]: payload
} = event;
// Lookup endpoint and invoke
return Promise.try(() => this.resolveEndpoint(operation))
.spread((endpoint, metadata) => {
/**
* Object bound as `this` value when an {@link Endpoint} is invoked as the
* last stage of the event-handling lifecycle. There are a few reasons for
* binding to the new context:
* <ol>
* <li>Provide only the invoked {@link Endpoint} with `event` and `context`
* without needing to make these values universally available.</li>
* <li>Allow parameters passed to the endpoint to be only those that are
* locally relevant, such as the `payload`. In the future, this may include
* providing additional filters or middleware.</li>
* <li>Implement a more functional approach with no shared state so that
* the handler has no real side effects to the `context` or `event`. This
* enables future event handling schemes such as fanning out the event to
* multiple endpoints.</li>
* </ol>
* @typedef {Object} InvocationContext
* @property {EndpointMetadata} metadata - metadata of current endpoint
* @property {string|number} operation - operation key of current endpoint
* @property {Object} event - the event being handled
* @property {AWSLambdaContext} [context] - the AWS Lambda context
* @since 2.0.0
*/
const thisArgs = {
metadata: metadata,
operation: operation,
event: event,
context: context
};
return this.invoke(endpoint, thisArgs, payload, ...args);
})
.then(onSuccess, onFailure);
}
/**
* Creates an {@link InvocationContext} by creating a new object with the current
* {@link Handler} instance as the prototype, assigning the properties from
* `thisArgs` as read-only, enumerable own properties of the object and _optionally_
* performing a deep copy of the values in `thisArgs` to make them deeply immutable.
* The resulting object is then used as the `this` value during invocation of
* the {@link Endpoint}.
* @param {Object} [thisArgs] - object containing assignable values
* @return {InvocationContext} that can be used to invoke an endpoint
* @throws {TypeError} if `thisArgs` is not `undefined` or `null` and isn't an object
* @since 2.0.0
* @experimental Deep copying is disabled by default. To enable, set options.deepCopy
* to a truthy value. This feature requires further testing to make sure it
* operates as expected within the AWS environment.
*/
createInvocationContext(thisArgs) {
let descriptors;
if (thisArgs != null) { //eslint-disable-line no-eq-null,eqeqeq
if (typeof thisArgs !== 'object') {
throw new TypeError(`thisArgs must be an object`);
}
descriptors = createCopyDescriptors(thisArgs, this.options.deepCopy);
}
return Object.create(this, descriptors);
}
/**
* Invokes a endpoint (function) with payload and optional arguments.
* @param {Function} endpoint - the endpoint to invoke
* @param {Object} thisArgs - additional data to augment "this" during invocation
* @param [payload] - the payload value of the event
* @returns {Promise} a promise containing the result of invocation.
* @private
* @since 2.0.0
*/
invoke(endpoint, thisArgs, payload, ...args) {
// Synchronously (w/out Promise) invoke the endpoint
const _invoke = () => {
const ictx = this.createInvocationContext(thisArgs);
return endpoint.call(ictx, payload, ...args);
}
return Promise.try(_invoke);
}
/**
* Resolves an operation name to a prototype method of a derived class. This
* method is a wrapper for {@link Handler.getEndpointMetadata} that type checks
* the operation name to make sure it isn't `null` or `undefined`, then attempts
* to retrieve the endpoint and metadata.
* @param {string} operation - the name of the operation to resolve
* @return {Array} - an array with two elements: the endpoint function and the metadata
* @property {Endpoint} 0 - the endpoint function
* @property {EndpointMetadata} 1 - the endpoint metadata
* @throws {TypeError} if `operation` is not a string, or if the endpoint has
* invalid metadata.
* @throws {Error} if an endpoint cannot be found
* @since 2.0.0
*/
resolveEndpoint(operation) {
checkType(operation, 'operation', ['string']);
// Throw the same error for not found and for metadata issues
const notFound = () => {
throw new Error(`endpoint not found for operation "${operation}"`);
};
// Get endpoint and metadata (or throw)
const endpoint = this[operation];
if (!isUndefinedOrNull(endpoint)) {
try {
const metadata = Handler.getEndpointMetadata(endpoint);
if (!isUndefinedOrNull(metadata)) {
return [endpoint, metadata];
}
} catch (e) {
console.error(e); //eslint-disable-line no-console
}
}
// Got here, so the endpoint couldn't be found
notFound();
}
}
/**
* Operation decorator for handler methods. Decorating a method in a `Handler`
* subclass will attach a {@link EndpointMetadata} to that method, making it
* visible as an operation {@link Endpoint}.
* @param {Object} target - the target class of the decorator
* @param {string} key - the key used to access the method being decorated
* @param {Object} descriptor - the property descriptor of the method
* @return {Object} the modified property descriptor of the method
* @since 2.0.0
* @example
*
* import { Handler, operation } from 'lambda6'
*
* class TestHandler extends Handler {
*
* @operation
* decoratedOperation() { return 'handled'; }
*
* }
*/
export function operation(target, key, descriptor) {
const endpoint = target[key];
Handler.validateEndpoint(endpoint);
endpoint[Handler.metadataKey] = Object.assign({}, _defaultMetadata);
descriptor.enumerable = true; // make visible for operation introspection
return descriptor;
}