Package Exports
- layer8
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (layer8) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
Layer8
An organized yet versatile web services framework (built on top of Koa).
Key features
- Designed around RESTful endpoints
- Built in authentication and password hashing
- Thorough input data validation
- Organized routing/controller setup
- Pre/post execution hooks for transaction code
Philosophy
When designing Layer8, the goal was to standardize the process of adding RESTful endpoints while eliminating a lot of the boilerplate necessary to accomplish this with frameworks such as Koa or Express. Extending web services should be easy, with as little boilerplate as possible, allowing the developer to focus on business logic rather than framework.
The server
Layer8 is built around Koa which has served as a reliable base for writing web services. Below is a sample illustrating the most basic server configuration:
const { Server } = require('layer8');
const appServer = new Server(
[
...controllerInstances,
],
);
appServer.server.listen(8888);
The basic server setup is very minimal. The first argument is an array of controller instances. Each controller defines its own routing. In a more complex setup below, we can see how method execution can be wrapped in a transaction block.
const { Server } = require('layer8');
const appServer = new Server(
[
...controllerInstances,
],
() => {
// Callback for when endpoint execution begins
// myTransaction.begin();
},
() => {
// Callback for when endpoint execution completes (only if no exception is thrown)
// myTransaction.commit();
},
() => {
// Callback for when endpoint execution fails (only if exception is thrown)
// myTransaction.rollback();
}
);
appServer.server.listen(8888);
Of course transaction management is only one factet of what these callbacks can be used for, but the point is adequately illustrated. Additionally, an array of middlewares can be passed into the Server
constructor, which will execute in order, on every endpoint prior to execution. This is useful for things such as endpoint timing, etc. Middlewares take the standard form of:
@param {object} ctx - The Koa context object (request info, etc.)
@param {function} next - The next middleware in the stack to execute
async (ctx, next) => {
// Insert code here
await next();
}
Controllers
Controllers both define and implement the endpoint and all supported methods of said endpoint. Layer8 controllers support the following methods / pseudomethod:
- INDEX
- GET
- POST
- PUT
- DELETE
INDEX
is actually a GET
method, but facilitates the common use case whereby an entity ID is not supplied and all entities are requested for the particular endpoint.
Each HTTP method is broken into two supporting methods on the controller object: one facilitates input data validation, and the other performs execution based on the validated data.
In this example, a controller with 2 methods is illustrated:
const {
Controller,
Endpoint,
ResponseObject,
RedirectResponse,
Accessor,
} = require('layer8');
const body = require('koa-body');
const TestAccessors = require('../api/TestAccessors');
class TestController extends Controller {
constructor() {
super(
'/test',
[
new Endpoint('/', Endpoint.INDEX),
new Endpoint('/', Endpoint.POST, [body()]),
]
);
}
async validateIndex(ctx, session) {
// Since there is no input data, there is nothing to validate
return [];
}
async executeIndex(session) {
// Execution goes in here. Each execute method should return a subclass
// of the ResponseObject. In this case we'll return a simple hello world
return new ResponseObject("Hello world")
}
async validatePost(ctx, session) {
// Validates the input data parsed from the form data
return Accessor.validateAll(
ctx.request.body,
[
TestAccessors.FIRST_NAME,
TestAccessors.LAST_NAME,
TestAccessors.EMAIL,
]
);
}
async executePost(session, firstName, lastName, email) {
// Do something with the data, then return a response (in this case a redirect)
return RedirectResponse("/thank_you");
}
}
module.exports = TestController;
When writing a new controller, we first subclass the Controller
class, which provides the basic interface for building our own controllers. Here we've supplied implementations for 2 methods (INDEX
and POST
), by providing those Endpoint
objects in the constructor, and then overriding the respective base methods to perform both validation and execution.
Let's take a closer look at the constructor:
constructor() {
super(
'/test',
[
new Endpoint('/', Endpoint.INDEX),
new Endpoint('/', Endpoint.POST, [body()]),
]
);
}
Here, we're invoking the base class constructor and passing the endpoint's base path as the first argument. In this case /test
. We then supply 2 endpoints, each of which has its own path suffix, and method. In this case, both path suffixes are /
, which means there is no additional appendage to the /test
path. Effectively we are supporting the INDEX
and POST
methods when a vistor hits the /test
endpoint.
In the case of
new Endpoint('/', Endpoint.POST, [body()])
We are supplying an array of middlewares, in this case, the koa-body
body parser, which will automatically extract supplied form data and make it available on the koa context as ctx.request.body
. We then perform validation in the validatePost
method, using 3 accessors, each of which defines a piece of data to be validated, and the validation technique. We'll get more into accessors later.
Once the controller is written, its routings get added to the server by instantiating the controller and passing in into the Server
constructor in the array of controllers. In this example we would do:
const { Server } = require('layer8');
const TestController = require('./controllers/TestController`)
const appServer = new Server(
[
new TestController(),
],
);
appServer.server.listen(8888);
Endpoints
The Endpoint
class defines an endpoint's path extension, HTTP method, and any middlewares to execute when a visitor visits that specific endpoint. Endpoint instances are passed to the Controller
at the time of instantiation and determine which routings are available to the controller.
The Endpoint
constructor takes the following arguments:
@param {string} relativePath - The path of the endpoint relative to the controller's path.
@param {string} method - The HTTP method which the endpoint accepts (See Endpoint.METHODS)
@param {Array|null} [middlewares=null] - An array of middlewares to apply to the endpoint (or null for none)
Here are a few example endpoints to illustrate their construction:
/* An endpoint with no change relative to the controller's path, allowing the HTTP GET method */
new Endpoint('/', Endpoint.GET);
/* An endpoint with no change relative to the controller's path, allowing the HTTP GET method, receiving a path argument representing an entity ID.
*/
new Endpoint('/:id', Endpoint.GET);
/* An endpoint with /user appended to the controller's path, allowing the HTTP PUT method, receiving a path argument representing an entity ID */
new Endpoint('/user/:id', Endpoint.PUT);
/* An endpoint with /user appended to the controller's path, allowing the HTTP POST method, receiving multiple path arguments */
new Endpoint(':parent/user/:id', Endpoint.POST);
In the examples above, you can see that arguments can be defined in the URL path by prefixing a name with a :
. The names can be anything you like, and these path arguments will be exposed on the koa context as ctx.params
. They can be validated just like any other input data using the accessors.
Endpoints are added to a Controller
via its constructor as seen below:
constructor() {
super(
'/example',
[
new Endpoint('/', Endpoint.INDEX),
new Endpoint('/:id', Endpoint.GET),
new Endpoint('/:id', Endpoint.PUT),
new Endpoint('/:id', Endpoint.POST),
]
);
}
Accessors
Subclasses of the Accessor
object perform data translation and validation. They are called accessors because they are used to access data within objects. Layer8 provides several useful accessors out of the box, but you can create new ones in order to provide any desired translation/validation by simply subclassing the Accessor
object, or any of its subclasses, and overriding the validate
method.
The following accessors are provided by Layer8
- ArrayAccessor - used to validate an array of data
- EmailAccessor - used to validate email addresses
- EnumAccessor - used to validate an item as a member of a known collection of items
- IntAccessor - used to validate integers
- NumericAccessor - used to validate any numeric data
- PasswordAccessor - used to validate passwords with varying complexity
- PathEntityIDAccessor - used to validate entity IDs which may be represented as strings, as part of the URL path, etc.
- PositiveIntAccessor - used to validate positive integers
- PositiveNumericAccessor - used to validate positive numeric values
- StringAccessor - used to validate strings
Accessors make it very easy to define endpoint inputs and their respective data types and ranges. They are crucial as a first line of defense against bad client data, ensuring that only expected data is passed to the server.
In the example application's accessors, one set of accessors has been defined per endpoint (where validation is present).
The SignupAccessor provides an excellent illustration of using both the canned accessor objects, as well as implementing a new one for a custom validation job.
In the example:
SignupAccessors.FIRST_NAME = new StringAccessor('first_name').range(1, 50).trim().noSpaces();
SignupAccessors.LAST_NAME = new StringAccessor('last_name').range(1, 50).trim().noSpaces();
SignupAccessors.EMAIL = new EmailAccessor('email').trim();
SignupAccessors.PASSWORD = new StringAccessor('password').range(8, 200);
We can see that 4 simple accessors are instantiated and will be reused by the controller on each request. The accessors define the attribute where the data can be found on the target object, using a dot delimited notation, as well as other key information such as whether or not the data is required, any default value, or any length constraints, etc. You will have to review the specific arguments of each type of accessor in order to determine its specific use.
The accessors would subsequently be used in the specific validation method on the controller. For instance, if we were validating signup data as defined by the accessors above, we'd place them within the validatePost
method, in order to validate the input form data, and provide output via an array to the execute method.
class SignupController extends Controller {
.
.
.
async validatePost(ctx, session) {
// Validates the input data parsed from the form data
return Accessor.validateAll(
ctx.request.body,
[
SignupAccessors.FIRST_NAME,
SignupAccessors.LAST_NAME,
SignupAccessors.EMAIL,
SignupAccessors.PASSWORD,
]
);
}
async executePost(session, firstName, lastName, email, password) {
.
.
.
}
The Accessor.validateAll
helper method simply takes an input object, and an array of accessors, then returns an array of validated data, in the same order that the accessors were provided.
Nested data can be accessed using .
separated notation, such as:
new PositiveNumericAccessor('user.job.salary')
In which case the object would be recursively traversed until the data is acquired. Accessors can also be used directly without the Accessor.validateAll
helper method.
async validatePost(ctx, session) {
const body = ctx.request.body;
return [
SignupAccessors.FIRST_NAME.validate(body),
SignupAccessors.LAST_NAME.validate(body),
.
.
.
]
}
It is important to note, that an Accessor
instantiated with a null
key will attempt to validate the object directly when validate
is invoked, rather than attempting to look up the value on a target object. This is useful when using accessors to evaluate arrays of items, etc., where object lookup is not necessary.
Authenticators
Layer8 provides some authentication mechanisms, in addition to some utility classes to aid in the process of authentication. The authentication class provided out of the box is:
- TokenAuthenticator - Used to authenticate an authorization token on each access restricted request
Authenticators extend the Authenticator
class, and each must be further extended in order to be used. Below is an example of how the TokenAuthenticator
is fully implemented.
const { TokenAuthenticator } = require('layer8');
const assert = require('assert');
class MyTokenAuthenticator extends TokenAuthenticator {
static _instance = null;
constructor() {
super();
assert(
MyTokenAuthenticator._instance === null,
"MyTokenAuthenticator should be a singleton instance"
);
MyTokenAuthenticator._instance = this;
}
static use(ctx, next) {
if (MyTokenAuthenticator._instance === null) {
new MyTokenAuthenticator();
}
return MyTokenAuthenticator._instance.authenticate(ctx, next)
}
async _doAuthentication(authToken) {
// Here, the developer implements application specific logic to
// authenticate the validity of the auth token. Cache checking,
// database checking, expiration date/time checking.
// A valid auth token results in the returning of a session object (just
// a regular javascript object). An invalid auth token results in the
// return of null.
}
}
module.exports = MyTokenAuthenticator;
In the above example, there are a couple of things going on. One, we've implemented MyTokenAuthenticator
as a singleton. This way, the same instance can be reused on each request. We can also simply pass MyTokenAuthenticator.use
as a middleware, to any endpoint / controller which requires authentication.
TokenAuthenticator
expects a bearer token located in the Authorization
header. Typically the client will provide this token after initial authentication of the user's credentials have taken place and a token is created.
Response objects
Each execution method of a controller should return a ResponseObject
. A ResponseObject
is used to format and return data to the client, passing necessary headers to indicate content type, etc. A controller method that does not return a ResponseObject
will return an empty body. This is acceptable when there is simply no data to return.
Layer8 implements a few subclasses of the ResponseObject
in order to generate some of the common response types.
ResponseObject
- A text/html response for rendering pages, etcJSONResponse
- Used to return JSON data to the clientRedirectResponse
- Issues a redirect to the clientErrorResponse
- Indicates an error and takes the form of a JSON payload
All of the above subclasses of the ResponseObject
can be passed headers and cookies to set on the response. See each individual class for constructor specifics.
Headers and cookies
Request headers and cookies are available on the Koa context object, please consult the Koa documentation for their use. Setting response headers and cookies are carried out through the ResponseObject
. Headers are set by passing a javascript object (key value pairs) to the respective ResponseObject
subclass's constructor method. Cookies are set by passing an array of one or more Cookie
objects to the ResponseObject
. Below illustrates through example, how this would be accomplished with a JSONResponse
object.
const {
Cookie,
JSONResponse
} = require('layer8');
const myResponse = new JSONResponse(
{ // The response body
message: 'hello world'
},
{ // Response headers
'User-Agent', 'my cool client',
},
[ // Response cookies
new Cookie(
'session',
'some serialized data',
new Date(new Date().getTime + (1000 * 60 * 60 * 24)),
'mydomain.com',
)
],
)
Helper utilities
Layer8 comes with one basic set of helper utilities used to facilitate authentication and secure password storage. These are the HashUtils
. See both the SessionService and UserService in the example application for examples on using the HashUtils
for password hash and salting as well as verification, and session token creation.