lambda-foundation
v3.0.3
Published
Common helpers for Lambda backed microservices
Downloads
172
Readme
Lambda Foundation
Lambda Foundation contains various shared parts that AWS Lambda backed microservices need and use. In its current state this package aims to simplify authentication, error reporting, service discovery (including URL resolving) and configuration.
Installation and Usage
npm install lambda-foundation
Then in code, you can import the entire foundation by:
var Foundation = require('lambda-foundation');
Notice that this package aims to be modular, allowing you direct access to the parts that are more important, for example to only get authentication, you can do either of the following:
var Auth = require('lambda-foundation/authentication');
var Auth = require('lambda-foundation').authentication;
Parts
Authentication
Authentication is an asynchronous process, that assumes the event contains a value under the authorization
key. This value must be an OAuth Bearer token, as defined in RFC 6750. The API returns a promise that fails if the context is not properly authenticated. Upon success, the promise resolves the token into its claims, which in general contain a sub
, exp
and iat
keys as per the JWT spec.
var Auth = require('lambda-foundation').authentication;
function handler(event, context) {
let authPromise = Auth.authenticate(event.authorization);
authPromise.then(function(jwt) {
console.log('Hi, ' + jwt.sub);
...
});
}
The secret, against which the token is validated can be configured in multiple ways. By default the secret is default_secret
, unless the environment variable AUTH_SECRET
is set on process.env
, in which case that value is used. Finally, the secret can be overriden in code via auth.config()
.
var Auth = require('lambda-foundation').authentication;
Auth.config({
secret: 'new_secret'
});
function handler(event, context) {
// code like before, now authentication is done against new_secret
}
The authentication submodule also includes constants for scopes and allows both authentication against a specific scope as well as offering a utility function for manually checking if a specific JWT has specific scope(s).
var Auth = require('lambda-foundation').authentication;
function handler(event, context) {
// We can provide a set of scopes and a rule that the event has to satisfy
// the rule defaults to "All", but can also be "Any" or "None"
let authPromise = Auth.authenticate(event.authorization, {
scopes: ['admin', 'user'],
rule: Auth.RULE.ANY
});
authPromise.then(function(jwt) {
// We now know that the user is either a client or an admin
console.log('Hi, ' + jwt.sub);
// We can check which exactly was satisfied
if (auth.verifyScopes(jwt, { scopes: ['admin'] })) {
// Admin
} else {
// Client
}
...
}).catch(function(err) {
// Either the event was not properly authenticated or the scope
// rule wasn't satisfied
});
}
Note: The errors thrown by authentication start with either 401:
or 403:
, depending on the reason (not matching scope vs having an invalid token). This means the AWS Lambda function results in a string that matches the regex 4\\d{2}:.*
Service Discovery
At the root of every microservice should be a discovery method for publicly accessible resources. Generally the structure of this discovery response is very similar between various services, which the Foundation aims to simplify. Furthermore, all services also rely on similar code for resolving/completing the HREFs present in the responses.
For example, a service may have a resource pet
and expose methods for obtaining all pets
for a user or a specific pet
. The discovery response would in that case be something along the lines of:
{
"resources": {
"pets": {
"href": "http://host/service/version/pets"
}
}
}
Responses to further methods/endpoints can also include HREFs to themselves and/or other resources:
{
"href": "http://host/service/version/pets/id",
"name": "Bob",
"type": "Dog"
}
The discovery
submodule of Lambda Foundation aims to help with both of these steps by reading a file from the service (discovery.json
) that defines the resources. Nested resources are also allowed (paths of resulting HREFs are assumed to nest). Notice this is very similar to the response format, but doesn't include the HREFs in the definition (as those are generated dynamically). If a resource wishes to not have a HREF generated (it is not directly accessible and serves as a passthrough for its children for example), it can specify the passthrough
flag as true
.
{
"resources": {
"pets": {},
"people": {
"passthrough": true,
"resources": {
"children": {},
"adults": {}
}
}
}
}
This results in resolved resources:
{
"resources": {
"pets": {
"href": "http://host/service/version/pets"
},
"people": {
"children": {
"href": "http://host/service/version/people/children"
},
"adults": {
"href": "http://host/service/version/people/adults"
}
}
}
}
In code these generated resources can be accessed directly:
var Discovery = require('lambda-foundation').discovery;
function handler(event, context) {
var resources = Discovery.resources;
context.succeed(resources);
}
Discovery also helps with resolving HREFs for use in responses. This works hand in hand with Node.js's URL module.
var Discovery = require('lambda-foundation').discovery;
var url = require('url');
function handler(event, context) {
...
// HREF for a specific pet
var href = url.resolve(Discovery.resources.pets.href, '/specific-pet-id');
// HREF for all pets (the top-level resource)
var generalHref = Discovery.resources.pets.href;
// HREF for a nested resource
var childrenHref = Discovery.resources.people.children.href;
...
}
Configuration
As Lambda based services that are deployed using lambda-tools undergo a bundling/browserifying/minifying process, which aims to reduce the entire Lambda function down to a single minified file. This process doesn't work well with the config package due to its dynamic require statements.
This problem could be solved in multiple ways, for example the config package could be ignored by bundling and then separately included in the resulting zipped up Lambda function. However, there may exist services which don't care about configuration, or want to handle it without using the aforementioned config package. Thus, Lambda Foundation aims to provide a simpler, more browserify friendly configuration method, which also provides some out of the box values that Lambda functions are likely to care about.
Configuration utilises a similar approach to the config package, leveraging files in the config
directory. The biggest difference is in the way lambda-foundation configuration is bundled/built into a static file by browserify. As expected, configurations in the config
directory are loaded based on the environment, with default
used as a fallback. All environment mappings defined in custom-environment-variables
are also loaded at initialisation time to allow bundling.
var Config = require('lambda-foundation').configuration;
function handler(event, context) {
...
// Read a value from configuration
var projectName = Config.project.name;
...
}
Error Reporting
Reporting errors from Lambda functions involves two steps, first the error has to be caught and reported, second the context should fail with the same error once the reporting code has finished. Foundation uses promises once again to offer error reporting facilities, which context.fail
can then be chained into. At the moment the error reporting is synchronous (as a process), meaning once the promise resolves the error has been reported to the centralised system. This means a small overhead on Lambda invocations that result in an error. However, this may change in the future, into a system where errors are batched and propagated separately from the Lambda functions that reported them.
The general error reporting function works as follows:
var Error = require('lambda-foundation').error;
function handler(event, context) {
let work = ...// some promise
promise.then(context.done).catch(function (err) {
return Error.report(err).then(context.fail);
});
}
All errors that don't start with the following pattern \\d{3}:
are assumed to be internal errors and are transformed into errors that have 500:
at the start of their message
.
The error submodule also aims to provide structure for errors used in Lambda functions. This is due to the way Lambda results are interpreted by API Gateway, where the error is converted into a string, that in turn gets mapped to an HTTP response. Thus, a nice pattern is to provide the suggested status code as the first part of the error message, such as 500:
or 404:
. Such error messages can be created as follows:
var Error = require('lambda-foundation').error;
function handler(event, context) {
let work = new Promise(function(resolve, reject) {
// Check some variable on event, which if not there, should result in a 400
if (!event.variable) {
return reject(new Error('400', 'Missing variable'));
}
// Everything is fine and work can continue
...
}).then(context.done).catch(function(err) {
return Error.report(err).then(context.fail);
});
}
If the environment variable RAYGUN_API_KEY
is set to a valid Raygun API key, then the errors are also reported there upon calls to .report
. Furthermore, errors are only reported when the NODE_ENV
environment variable is set to production
.
Model layer
Big part of any service is data, in Lambda backed microservices, that data is usually kept in DynamoDB. In order to standardise this as well as make it more convenient to use from the Lambda function, Foundation includes a separate submodule for defining and interacting with models. The model layer is largely based on vogels, but offers an API that uses promises, which better suit the workflow Foundation proposes.
In a service, a model object/type can be defined as follows:
var model = require('lambda-foundation').model;
var joi = require('joi');
const example = model('Example', {
hashKey : 'guid',
timestamps : true,
schema : {
guid: model.types.uuid(),
type: joi.string(),
name: joi.string()
},
indexes: [{
hashKey: 'guid',
name: 'ExampleIndex',
type: 'global'
}]
});
module.exports = example;
The API mimics that of Vogels, but is promisified, thus allowing for better chaining:
example.create({ guid: '111', type: 'example', name: 'Name' }).then(function(value) {
console.log(value.name) // outputs 'Name'
});
APIs that are promisified include: find
, findItems
, create
, update
, destroy
, query
and scan
. It is worth noting that the object returned from the model()
function used above has all the same properties as a Vogels' table would, and it can be extended to fit the custom needs of the service (for example by adding a custom findByVariable
function).
The model layer also automatically configures the underlying Vogels/DynamoDB connection to use the appropriate table. This uses the provided model object name along with the project name and stage to build the table name. The model name is converted to kebab-case in the table name.
Testing
Our testing module provides three main submodules described below.
Context
We provide a mock context for easier verification of lambda function results. Our mock context does depend on Tape and handles basic result assertion and test ending. Lambda is considered to have successfully executed if it terminates with either context.succeed(result)
or context.done(null, result)
.
The following examples both demonstrate a basic test, checking whether the lambda under test finishes with the expected result.
var tape = require('tape');
var context = require('lambda-foundation').test.context;
tape.test('Example', function(t) {
// we expect the context to succeed with the second parameter i.e. context.succeed({ test: 'result'}) or context.done(null, { test: 'result'}) is called
var mockContext = context.assertSucceed(t, { test: 'result'});
lambda.handler(event, mockContext);
});
You can also provide a callback with custom assertions:
var tape = require('tape');
var context = require('lambda-foundation').test.context;
tape.test('Example', function(t) {
var mockContext = context.assertFail(t, function(err) {
t.same(err, new Error('401', 'Unauthorized'));
});
lambda.handler(event, mockContext);
});
Event
Our event submodule provides a way to easily create either authorized or unauthorized events.
var tape = require('tape');
var Event = require('lambda-foundation').test.event;
tape.test('Example', function(t) {
// Creates an event object with an invalid authorization token
var event = Event().unauthorized();
lambda.handler(event, context);
});
tape.test('Example', function(t) {
// Creates an event object with a valid authorization token signed with 'default_secret'
// and additional properties passed in the constructor
var event = Event({extra: 'property'}).authorized();
lambda.handler(event, context);
});
tape.test('Example', function(t) {
// Creates an event object with a valid authorization token signed with a custom secret
// passed as parameter
var event = Event().authorized('secret');
lambda.handler(event, context);
});
Lambda test
Lastly, we have the lambda-test submodule. This submodule wraps a Tape test group and provides you with a Sinon sandbox for easy mocking. The submodule takes care of restoring any mocks after the test run.
var test = require('lambda-foundation').test.test;
var customModule = require('custom');
test.test('Example test group', function(sandbox, tape) {
sandbox.stub(customModule, 'method', function(result) {
//skip complicated logic
return result;
});
tape.test('Example test' function(t) {
lambda.handler(event, context);
});
});
We also provide a basic authorization test - it creates an event with an invalid token and asserts that the lambda under test fails with the expected error (new Error('401', 'Invalid token')
by default).
var test = require('lambda-foundation').test.test;
test.test('Example test group', function(sandbox, tape) {
tape.testAuthorization(lambda, error);
});
All together now
Thus, using our testing module, a basic lambda test would look like this:
var lambdaTest = require('lambda-foundation').test;
var context = lambdaTest.context;
var Event = lambdaTest.event;
var test = lambdaTest.test;
var Error = require('lambda-foundation').error;
var lambda = require('path/to/lambda');
test.test('Example test group', function(sandbox, tape) {
tape.testAuthorization(lambda);
tape.test('Example successful context test' function(t){
var mockContext = context.assertSucceed(t, {test: 'result'});
lambda.handler(Event().authorized(), mockContext);
});
tape.test('Example failed context test' function(t){
var mockContext = context.assertFail(t, new Error('400', 'Bad request')});
lambda.handler(Event({parameter: 'invalid_value'}).authorized(), mockContext);
});
});