Skip to content

Latest commit

 

History

History
1076 lines (851 loc) · 47 KB

README.md

File metadata and controls

1076 lines (851 loc) · 47 KB

Ravel

Forge past a tangle of modules. Make a cool app.

GitHub license npm version Dependency Status npm Build Status Build status Test Coverage js-semistandard-style

Ravel is a tiny, sometimes-opinionated foundation for creating organized, maintainable, and scalable web applications in node.js with ES2016/2017.

Note: The main branch may be in an unstable or even broken state during development. Please use releases instead of the main branch to explore stable code.

Table of Contents

Introduction

Ravel is inspired by the simplicity of koa and express, but aims to provide a pre-baked, well-tested and highly modular solution for creating enterprise web applications by providing:

  • A standard set of well-defined architectural components so that your code stays organized
  • Rapid REST API definition
  • Easy bootstrapping via an enforced, reference configuration of koa with critical middleware
  • Dependency injection (instead of relative requires)

And a few other features, plucked from popular back-end frameworks:

  • Transaction-per-request
  • Simple authentication and authentication configuration (no complex passport setup)
  • (Optional) externalized session storage for horizontal scalability

Ravel is deliberately designed to minimize unnecessary dependencies and have a small, well-documented codebase, making it easier to create secure and robust applications you and your users can trust.

Ravel is layered on top of, and designed to be used with, awesome technologies, including:

Installation

As Ravel uses async/await and several other ES2015/2016 features, you will need to use a 8.0.x+ distribution of node

$ npm install ravel

Architecture

Ravel applications consist of a few basic parts:

  • Modules: plain old classes which offer a great place to define modular application logic, middleware, authentication logic, etc.
  • Middleware a familiar concept from express or koa-like frameworks, middleware are chained functions which run in sequence against a request to a specific route.
  • Routes: a low-level place for general routing logic
  • Resources: built on top of Routes, Resources are REST-focused
  • Errors: Node.js Errors which are associated with an HTTP response code. throw them in your code and Routes and Resources will automatically produce responses with a matching status.

If you're doing it right, your applications will consist largely of Modules, with a thin layer of Routes and Resources on top.

Modules (and Errors)

Modules are plain old node.js modules exporting a single class which encapsulates application logic. Modules support dependency injection of core Ravel services and other Modules alongside npm dependencies (no relative require's!). Modules are instantiated safely in dependency-order, and cyclical dependencies are detected automatically.

For more information about Modules, look at Ravel.Module below.

modules/cities.js

const Ravel = require('ravel');
const Error = Ravel.Error;
const Module = Ravel.Module;
const inject = Ravel.inject;

/**
 * First, we'll define an Error we will throw when a requested
 * city is not found. This Error will be associated with the
 * HTTP error code 404.
 */
class MissingCityError extends Error {
  constructor (name) {
    super(`City ${name} does not exist.`, Ravel.httpCodes.NOT_FOUND);
  }
}

/**
 * Our main Module, defining logic for working with Cities
 */
@inject('moment', '$log')
@Module('cities')
class Cities {
  constructor (moment, $log) {
    this.moment = moment;
    this.$log = $log
    this.cities = ['Toronto', 'New York', 'Chicago']; // our fake 'database'
  }

  getAllCities () {
    return Promise.resolve(this.cities);
  }

  getCity (name) {
    return new Promise((resolve, reject) => {
      const index = this.cities.indexOf(name);
      if (index !== -1) {
        resolve(this.cities[index]);
      } else {
        // Ravel will automatically respond with the appropriate HTTP status code!
        this.$log.warn(`User requested unknown city ${name}`);
        reject(new MissingCityError(name));
      }
    });
  }
}

// Export Module class
module.exports = Cities;

Middleware

Ravel middleware takes the form of an async function and is defined within Modules, either directly or via a factory pattern:

modules/cities.js

const Ravel = require('ravel');
const Module = Ravel.Module;
const middleware = Module.middleware;
class MyMiddleware {
  // this middleware will be available by name elsewhere in the application
  @middleware('custom-middleware')
  async doSomething(ctx, next) {
    // ... do something before the next middleware runs
    await next();
    // ... do something after the next middlware runs
  }

  // this middleware is also available elsewhere by name,
  // but is a factory that can receive two arguments
  @middleware('another-middleware', { factory: true })
  anotherMiddlewareFactory (arg1, arg2) {
    return async (ctx, next) {
      await next();
    }
  }
}

Routes

Routes are Ravel's lower-level wrapper for koa (Resources are the higher-level one). They support HEAD, GET, POST, PUT, PATCH and DELETE requests, and middleware, via decorators. Like Modules, they also support dependency injection. Though Routes can do everything Resources can do, they are most useful for implementing non-REST things, such as static content serving, proxying, etc. If you want to build a REST API, use Resources instead (they're up next!).

For more information about Routes, look at Ravel.Routes below.

routes/index.js

const Ravel = require('ravel');
const Routes = Ravel.Routes;
const inject = Ravel.inject;
const before = Routes.before; // decorator to chain middleware before an endpoint
const mapping = Routes.mapping; // decorator to associate a handler method with an endpoint

@Routes('/') // base path for all routes in this class. Will be prepended to the @mapping.
class ExampleRoutes {
  // bind this method to an endpoint and verb with @mapping. This one will become GET /app
  @mapping(Routes.GET, 'app')
  @before('custom-middleware') // use @before to place multiple middleware (comma-separated names) before appHandler - these could be npm modules, functions on this scope, or defined via @middleware
  async appHandler (ctx) {
    // ctx is just a koa context! Have a look at the koa docs to see what methods and properties are available.
    ctx.body = '<!DOCTYPE html><html><body>Hello World!</body></html>';
    ctx.status = 200;
  }

  @mapping(Routes.GET, 'log')
  @before('another-middleware', 1, 2, 'custom-middleware') // use @before to instantiate middleware which accepts arguments, alongside middleware which doesn't.
  async logHandler (ctx) {
    // ...
  }
}

// Export Routes class
module.exports = ExampleRoutes;

Resources

What might be referred to as a controller in other frameworks, a Resource module defines HTTP methods on an endpoint, supporting the session-per-request transaction pattern via Ravel middleware. Resources also support dependency injection, allowing for the easy creation of RESTful interfaces to your Module-based application logic. Resources are really just a thin wrapper around Routes, using specially-named handler functions (get, getAll, head, headAll, post, put, putAll, patch, patchAll, delete, deleteAll) instead of @mapping. This convention-over-configuration approach makes it easier to write proper REST APIs with less code, and is recommended over "carefully chosen" @mappings in a Routes class.

For more information about Resources, look at Ravel.Resource below.

resources/city.js

// Resources support dependency injection too!
// Notice that we have injected our cities Module by name.
const Ravel = require('ravel');
const Resource = Ravel.Resource;
const inject = Ravel.inject;
const before = Resource.before; // decorator to add middleware to an endpoint within the Resource

// using @before at the class level decorates all endpoint methods with middleware
@inject('cities')
@Resource('/cities') // base path for all routes in this Resource
class CitiesResource {
  constructor (cities) {
    this.cities = cities;
  }

  // no need to use @mapping here. Routes methods are automatically mapped using their names.
  async getAll (ctx) { // just like in Routes, ctx is a koa context.
    ctx.body = await this.cities.getAllCities();
  }

  @before('custom-middleware') // using @before at the method level decorates this method with middleware
  @before('another-middleware', 1, 2) // use @before multiple times for clarity, if desired
  async get (ctx) { // get routes automatically receive an endpoint of /cities/:id (in this case).
    ctx.body = await this.cities.getCity(ctx.params.id);
  }

  // post, put, putAll, delete and deleteAll are
  // also supported. Not specifying them for
  // this resource will result in calls using
  // those verbs returning HTTP 501 NOT IMPLEMENTED

  // postAll is not supported, because it makes no sense
}

// Export Resource class
module.exports = CitiesResource;

Bringing it all together

app.js

const app = new require('ravel')();

// parameters like this can be supplied via a .ravelrc.json file
app.set('keygrip keys', ['mysecret', 'anothersecret']);

app.scan('./modules'); //import all Modules from a directory
app.scan('./resources');  //import all Resources from a directory
app.scan('./routes/index.js');  //import all Routes from a file

// start it up!
app.start();

Decorator Transpilation

Since decorators are not yet available in Node, you will need to use a transpiler to convert them into ES2016-compliant code. We have chosen Babel as our recommended transpiler.

gulpfile.js

const babelConfig = {
  'plugins': [['@babel/plugin-proposal-decorators', { 'legacy': true }]]
};
gulp.task('transpile', function () {
  return gulp.src('src/**/*.js') // point it at your source directory, containing Modules, Resources and Routes
      .pipe(plugins.babel(babelConfig))
      .pipe(gulp.dest('dist'));  // your transpiled Ravel app will appear here!
});

Running the Application

$ node dist/app.js

API Documentation

View API docs 🕮

Ravel App

View API docs 🕮

A Ravel application is a root application file (such as app.js), coupled with a collection of files exporting Modules, Resources and Routes (see Architecture for more information). Getting started is usually as simple as creating app.js:

app.js

const Ravel = require('ravel');
const app = new Ravel();

(async () => {
  // you'll register managed parameters, and connect Modules, Resources and Routes here
  await app.init();
  // you'll set managed parameters here
  // ...
  // then start the server
  await app.listen();
})();

Managed Configuration System

Traditional node appliations often rely on process.env for configuration. This can lead to headaches when an expected value is not declared in the environment, a value is supplied but doesn't match any expected ones, or the name of an environment variable changes and refactoring mistakes are made. To help mitigate this common issue, Ravel features a simple configuration system which relies on three methods:

app.registerParameter

View API docs 🕮

Create managed parameters with app.registerParameter():

app.js

const Ravel = require('ravel');
const app = new Ravel();

// register a new optional parameter
app.registerParameter('my optional parameter');
// register a new required parameter
app.registerParameter('my required parameter', true);
// register a required parameter with a default value
app.registerParameter('my third parameter', true, 'some value');

(async () => {
  await app.init();
  await app.listen();
})();

Many Ravel plugin libraries will automatically create parameters which you will have to supply values for. These parameters will be documented in their README.md.

app.set

View API docs 🕮

Provide values via app.set(). Setting an unknown parameter will result in an Error.

app.js

const Ravel = require('ravel');
const app = new Ravel();

// register a new optional parameter
app.registerParameter('my optional parameter');

(async () => {
  await app.init();

  // set a value
  app.set('my optional parameter', 'some value');
  // this won't work:
  app.set('an unknown parameter', 'some value');

  await app.listen();
})();

app.get

View API docs 🕮

Retrieve values via app.get(). Retrieving an unknown parameter will result in an Error.

app.js

const Ravel = require('ravel');
const app = new Ravel();

// register a new parameter
app.registerParameter('my required parameter', true, 'default value');

(async () => {
  await app.init();

  // set a value
  app.set('my required parameter', 'some value');
  // get a value
  app.get('my required parameter') === 'some value';
  // this won't work:
  // app.get('an unknown parameter');

  await app.listen();
})();

Core parameters

Ravel has several core parameters:

// set initial keygrip keys for cookie signing (recommend at least 5):
app.set('keygrip keys', ['my super secret key', 'another super secret key']);

// these are optional (default values are shown):
app.set('redis host', undefined); // set to point to an external redis server (required for horizontal scaling).
app.set('redis port', 6379);
app.set('redis password', undefined);
app.set('redis max retries', 10); // connection retries
app.set('port', 8080); // port the app will run on
app.set('https', false) // listen for https instead of http
app.set('https options', {}) // any options to pass to the https server app.set('https', true). Supports options from https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener
app.set('log level', true, app.$log.DEBUG); // minimum log level
app.set('log format', true, '[%(date)s] %(name)s.%(levelname)s: %(message)s'); // log message format (see https://seanmonstar.github.io/intel/#logrecord for more details)
app.set('log colors', true, true); // whether or not to colorize log output
app.set('session key', 'ravel.sid'); // the cookie name to use for sessions
app.set('session max age', null); // session maxAge (default never expires)
app.set('session secure', true); // toggles Secure attribute for session cookies. true by default, and always true when app.get('https') is true.
app.set('session rolling', false); // force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown.
app.set('session samesite', null); // set the SameSite property on the session cookie (default not set, which can lead to inconsistent behaviour across different browsers)
app.set('app route', '/'); // if you have a UI, this is the path users will be sent to when they are logged in
app.set('login route', '/login'); // if users aren't logged in and you redirect them, this is where they'll be sent
app.set('public directory', undefined); // if you want to statically serve a directory
app.set('favicon path', undefined); // favicon middleware configuration
app.set('enable websockets', true); // set to false to disable websocket server
app.set('max websocket payload bytes', 100 * 1024 * 1024); // max payload size (in bytes) of a websocket message
app.set('redis websocket channel prefix', 'ravel.ws'); // default key prefix for websocket redis channels

.ravelrc.json

To make it easier to supply configuration values to Ravel, a .ravelrc.json file can be placed beside app.js (or in any parent directory of app.js). This is the recommended method of setting parameters.

.ravelrc.json

{
  "keygrip keys": ["my super secret key", "another super secret key"]
}

You can also use environment variables in .ravelrc.json file which will be interpolated using process.env at startup time:

{
  "keygrip keys": "$MY_SUPER_SECRET_KEY",
  "mysql connection string":
  "mysql:///$MYSQL_USER:$MYSQL_PASSWORD@$MYSQL_HOST:$MYSQL_PORT/$MYSQL_DB"
}

Ravel.Error

View API docs 🕮

This is the base Error type for Ravel, meant to be extended into semantic errors which can be used within your applications. When you create a custom Ravel.Error, you must provide an associated HTTP status code, which Ravel will automatically respond with if an HTTP request results in that particular Error being thrown. This helps create meaningful status codes for your REST APIs while working within traditional node error-handling paradigms (throw/try/catch and Promise.reject()). Errors are generally best-declared within Module, Resource or Routes files (and not exported), closest to where they are used. If necessary, create a Module to group and export them.

at the top of some Module, Resource or Routes file (we'll get to this next)

const Ravel = require('ravel');
/**
 * Thrown when a user tries to POST something unexpected to /upload
 */
class UploadError extends Ravel.Error {
  constructor (msg) {
    super(msg, Ravel.httpCodes.BAD_REQUEST);
  }
}

Ravel.Module

View API docs 🕮

Modules are meant to contain the bulk of your application logic, either to support endpoints defined in Resources and Routes, or to perform tasks at specific points during the Ravel lifecycle (see Lifecycle Decorators below).

Here's a simple module:

modules/my-module.js

const Ravel = require('ravel');
const inject = Ravel.inject; // Ravel's dependency injection decorator
const Module = Ravel.Module; // base class for Ravel Modules

// inject a custom ravel Module (or your plain classes) beside npm dependencies!
@Module('mymodule')
@inject('path', 'fs', 'custom-module', 'plain-class')
class MyModule {
  constructor (path, fs, custom, plain) { // @inject'd modules are available here as parameters
    this.path = path;
    this.fs = fs;
    this.custom = custom;
    this.plain = plain;
  }

  // implement any methods you like :)
  aMethod () {
    // ...
  }

  async anAsyncMethod () {
    // ...
  }
}

module.exports = MyModule; // you must export your Module so that Ravel can require() it.

Dependency Injection and Module Registration

View API docs 🕮

Ravel's dependency injection system is meant to address several issues with traditional require()s:

  • Using require() with one's own modules in a complex project often results in statements like this: require('../../../../my/module');. This issue is especially pronounced when require()ing source modules in test files.
  • Cyclical dependencies between modules are not always obvious in a large codebase, and can result in unexpected behaviour.

Ravel addresses this with the the @inject decorator:

modules/my-module.js

const Ravel = require('ravel');
const inject = Ravel.inject;
const Module = Ravel.Module;

@Module('mymodule')
@inject('another-module') // inject another Module from your project without require()!
class MyModule {
  constructor (another) { // @inject'd modules are available here as parameters
    this.another = another;
  }
}
module.exports = MyModule;

The injection name of another-module is inferred from its filename, but can be overriden via the @Module('custom-name') decorator.

If runnning app.scan('./modules'):

  • './modules/my-module' will be injectable as 'my-module'
  • './modules/another-module' will be injectable as 'another-module'
  • './modules/package/another-module' will be injectable as 'package.another-module'

Modules are singletons which are instantiated in dependency-order (i.e. if A depends on B, B is guaranteed to be constructed first). Cyclical dependencies are detected automatically and result in an Error.

To further simplify working with imports in Ravel, you can @inject Ravel services, the core node API, and npm dependencies (installed in your local node_modules or globally) alongside your own Modules:

const Ravel = require('ravel');
const inject = Ravel.inject;
const Module = Ravel.Module;

@Module('mymodule')
@inject('another-module', 'fs', 'moment', '$err') // anything that can be require()d can be @injected
class MyModule {
  constructor (another, fs, moment, $err) {
    // ...
  }
}
module.exports = MyModule;

To avoid constructors which simply perform assignments, Ravel includes the @autoinject decorator which can perform assignments for you:

modules/my-module.js

const Ravel = require('ravel');
const inject = Ravel.inject;
const Module = Ravel.Module;

@Module('mymodule')
@inject('another') // you can still mix using @inject!
@autoinject('fs', 'moment', '$err')
class MyModule {
  constructor (another) { // @inject'd modules are available here as parameters
    this.another = another;
    // @autoinjection takes place AFTER construction, so fs,
    // moment and $err are not available here.
  }
  method () {
    // this.fs, this.moment and this.$err are available here
  }
}
module.exports = MyModule;

Core Services

Several core Ravel services are available for injection within your Modules, Resources and Routes:

  • @inject('$app') - A reference to the ravel app object itself
  • @inject('$err') - Built-in error types
  • @inject('$log') - A logger scoped to the target module
  • @inject('$kvstore') - A reference to the internal redis connection (or mock, in the case where no external redis is supplied)
  • @inject('$params') - A read-only reference to the parameter system, to retrieve parameter values
  • @inject('$db') - A mechanism for creating scoped transactions. See Scoped Transactions below for more information.

Module Namespacing

In a large project, it may become desirable to namespace your Modules to avoid naming conflicts. This is easily accomplished with Ravel by separating source files for Modules into different directories. Let's assume the following project structure:

app.js
.ravelrc.json
modules/
  core/
    my-module.js
  util/
    my-module.js

Then, import the Module directory as before, using app.scan():

app.js

// ...
const app = new Ravel();
app.scan('./modules');
// core/my-module can now be injected using @inject(core.my-module)!
// util/my-module can now be injected using @inject(util.my-module)!

Essentially, Ravel ignores the path you pass to app.scan() and uses any remaining path components to namespace Modules.

Lifecycle Decorators

View API docs 🕮

Modules are also a great place to define logic which should run at particular points during the Ravel lifecycle. Decorating a Module method with a lifecycle decorator appropriately results in that method firing exactly once at the specified time (with the exception of @interval, of course):

const Ravel = require('ravel');
const Module = Ravel.Module;
const prelisten = Module.prelisten;

@Module('init-module')
class MyInitModule {
  // ...
  @prelisten
  initDBTables () {
    // ...
  }
}
module.exports = MyInitModule;

There are currently six lifecycle decorators:

  • @postinit fires at the end of Ravel.init()
  • @prelisten fires at the beginning of Ravel.listen()
  • @postlisten fires at the end of Ravel.listen()
  • @preclose fires at the beginning of Ravel.close()
  • @interval(1000) fires at the end of Ravel.listen() and then repeatedly at the specified interval until Ravel.close()
  • @koaconfig fires during Ravel.init(), after Ravel is finished configuring the underlying koa app object with global middleware. Methods decorated with @koaconfig receive a reference to the underlying koa app object for customization. This decorator is meant for exceptional circumstances, since (unnecessarily) global middleware constitutes a hot path and can lead to inefficiency.

Ravel.Routes

View API docs 🕮

Routes are Ravel's abstraction of koa. They provide Ravel with a simple mechanism for registering koa routes, which should (generally) only be used for serving templated pages or static content (not for building RESTful APIs, for which Ravel.Resource is more applicable). Extend this abstract superclass to create a Routes module.

Like Modules, Routes classes support dependency injection, allowing easy connection of application logic and web layers.

Endpoints are created within a Routes class by creating an async method and then decorating it with @mapping. The @mapping decorator indicates the subpath for the route (concatenated with the base path passed to super() in the constructor), as well as the HTTP verb. The method handler accepts a single argument ctx which is a koa context. Savvy readers with koa experience will note that, within the handler, this refers to the instance of the Routes class (to make it easy to access injected Modules), and the passed ctx argument is a reference to the koa context.

Unlike @koa/router, Ravel's internal router attempts to ensure predictability at runtime, by sorting routes predictably according to a set of rules. As a result, declaration order should have significantly less impact on the function of your routes, which is critical given that that order is not immediately obvious due to Ravel's dependency-injection-focused approach.

routes/my-routes.js

const inject = require('ravel').inject;
const Routes = require('ravel').Routes;
const mapping = Routes.mapping; // Ravel decorator for mapping a method to an endpoint
const before = Routes.before;   // Ravel decorator for conneting middleware to an endpoint

// you can inject your own Modules and npm dependencies into Routes
@inject('koa-bodyparser', 'fs', 'custom-module')
@Routes('/') // base path for all routes in this class
class MyRoutes {
  // The constructor for a `Routes` class must call `super()` with the base
  // path for all routes within that class. Koa path parameters such as
  // :something are supported.
  constructor (bodyParser, fs, custom) {
    this.bodyParser = bodyParser(); // make bodyParser middleware available
    this.fs = fs;
    this.custom = custom;
  }

  // will map to GET /app
  @mapping(Routes.GET, 'app'); // Koa path parameters such as :something are supported
  @before('bodyParser') // use bodyParser middleware before handler. Matches this.bodyParser created in the constructor.
  async appHandler (ctx) {
    ctx.status = 200;
    ctx.body = '<!doctype html><html></html>';
    // ctx is a koa context object.
    // await on Promises and use ctx to create a body/status code for response
    // throw a Ravel.Error to automatically set an error status code
  }
}

module.exports = MyRoutes;

Registering Routes

Much like Modules, Routes can be added to your Ravel application via app.scan('path/to/routes'):

app.js

// ...
const app = new Ravel();
app.scan('./routes');

Ravel.Resource

View API docs 🕮

What might be referred to as a controller in other frameworks, a Resource module defines HTTP methods on an endpoint. Resources also support dependency injection, allowing for the easy creation of RESTful interfaces to your Module-based application logic. Resources are really just a thin wrapper around Routes, using specially-named handler methods (get, getAll, post, put, putAll, delete, deleteAll) instead of @mapping. This convention-over-configuration approach makes it easier to write proper REST APIs with less code, and is recommended over carefully chosen @mappings in a Routes class. Omitting any or all of the specially-named handler functions is fine, and will result in a 501 NOT IMPLEMENTED status when that particular method/endpoint is requested. Resources inherit all the properties, methods and decorators of Routes. See core/routes for more information. Note that @mapping does not apply to Resources.

As with Routes classes, Resource handler methods are async functions which receive a koa context as their only argument.

resources/person-resource.js

const inject = require('ravel').inject;
const Resource = require('ravel').Resource;
const before = Routes.before;

// you can inject your own Modules and npm dependencies into Resources
@inject('koa-bodyparser', 'fs', 'custom-module')
@Resource('/person') // base path for all routes in this class
class PersonResource {
  constructor(convert, bodyParser, fs, custom) {
    this.bodyParser = bodyParser(); // make bodyParser middleware available to @before within this class
    this.fs = fs;
    this.custom = custom;
  }

  // will map to GET /person
  @before('bodyParser') // use bodyParser middleware before handler
  async getAll (ctx) {
    // ctx is a koa context object.
    // await on Promises, and set ctx.body to create a body for response
    // "OK" status code will be chosen automatically unless configured via ctx.status
    // Extend and throw a Ravel.Error to send an error status code
  }

  // will map to GET /person/:id
  async get (ctx) {
    // can use ctx.params.id in here automatically
  }

  // will map to POST /person
  async post (ctx) {}

  // will map to PUT /person
  async putAll (ctx) {}

  // will map to PUT /person/:id
  async put (ctx) {}

  // will map to DELETE /person
  async deleteAll (ctx) {}

  // will map to DELETE /person/:id
  async delete (ctx) {}
}

module.exports = PersonResource;

Registering Resources

View API docs 🕮

Much like Modules, Resources can be added to your Ravel application via app.scan('path/to/resources/directory'):

app.js

// ...
const app = new Ravel();
// directory scanning!
app.scan('./resources');

Response Caching

Ravel supports transparent response caching via the @cache decorator, which can be applied at both the class and method-level of Resources and Routes. Method-level applications of @cache override class-level ones.

Method-level example

const Routes = require('ravel').Routes;
const mapping = Routes.mapping;
const cache = Routes.cache;

@Routes('/')
class MyRoutes {
  @cache // method-level version only applies to this route
  @mapping(Routes.GET, '/projects/:id')
  async handler (ctx) {
    // The response will automatically be cached when this handler is run
    // for the first time, and then will be served instead of running the
    // handler for as long as the cached response is available.
    // If this handler throws an exception, then that response will not be cached.
  }
}

Class-level example, with options

const Resource = require('ravel').Resource;
const cache = Resource.cache;

// class-level version applies to all routes in class, overriding any
// method-level instances of the decorator.
@cache({expire:60, maxLength: 100}) // expire is measured in seconds. maxLength in bytes.
@Resource('/')
class MyResource {
  constructor (bodyParser) {
    this.bodyParser = bodyParser();
  }

  async get(ctx) {
    // The response will automatically be cached when this handler is run
    // for the first time, and then will be served instead of running the
    // handler for as long as the cached response is available (60 seconds).
  }
}

Database Providers

View API docs 🕮

A DatabaseProvider is a lightweight wrapper for a node database library (such as node-mysql) which performs all the complex set-up and configuration of the library automatically, and registers simple parameters which you must app.set (such as the database host ip). The true purpose of DatabaseProviders is to reduce boilerplate code between applications, as well as facilitate Ravel's transaction-per-request system (coming up next). You may use as many different DatbaseProviders as you wish in your application. Here's an example pulled from ravel-mysql-provider:

Example Setup

app.js

const app = new require('ravel')();
const MySQLProvider = require('ravel-mysql-provider');
new MySQLProvider(app, 'mysql');
// ... other providers and parameters
(async () => {
  await app.init();
})();
// ... the rest of your Ravel app

Example Configuration

.ravelrc.json

{
  "mysql options": {
    "host": "localhost",
    "port": 3306,
    "user": "root",
    "password": "a password",
    "database": "mydatabase",
    "idleTimeoutMillis": 5000,
    "connectionLimit": 10
  }
}

List of Ravel DatabaseProviders

Ravel currently supports several DatabaseProviders via external libraries.

If you've written a DatabaseProvider and would like to see it on this list, contact us or open an issue/PR against this README!

Transaction-per-request

View API docs 🕮

The @transaction decorator is Ravel's way of automatically opening (and managing) database connections for a Routes or Resource handler method. It is available for import as Routes.transaction or Resource.transaction.

When used at the method-level, @transaction opens connections for that specific handler method. When used at the class-level, it open connections for all handler methods in that Route or Resource class.

Connections are available within the handler method as an object ctx.transaction, which contains connections as values and DatabaseProvider names as keys. Connections will be closed automatically when the endpoint responds (do not close them yourself), and will automatically roll-back changes if a DatabaseProvider supports it (generally a SQL-only feature).

resources/person-resource.js

const Resource = require('ravel').Resource;
const transaction = Resource.transaction;

@Resource('/person')
class PersonResource {
  // maps to GET /person/:id
  @transaction('mysql') // this is the name exposed by ravel-mysql-provider
  async get (ctx) {
    // TIP: Don't write complex logic here. Pass ctx.transaction into
    // a Module function which returns a Promise! This example is
    // just for demonstration purposes.
    ctx.body = await new Promise((resolve, reject) => {
      // ctx.transaction.mysql is a https://github.com/felixge/node-mysql connection
      ctx.transaction.mysql.query('SELECT 1', (err, rows) => {
        if (err) return reject(err);
        resolve(rows);
      });
    });
  }
}
module.exports = PersonResource;

Scoped Transactions

View API docs 🕮

Sometimes, you may need to open a transaction outside of a code path triggered by an HTTP request. Good examples of this might include database initialization at application start-time, or logic triggered by a websocket connection. In these cases, a Module class can open a scoped transaction using the names of the DatabaseProviders you are interested in, and an async function (scope) in which to use the connections. Scoped transactions only exist for the scope of the async function and are automatically cleaned up at the end of the function. It is best to view Module.$db.scoped() as an identical mechanism to @transaction, behaving in exactly the same way, with a slightly different API:

modules/database-initializer.js

const Module = require('ravel').Module;
const autoinject = require('ravel').autoinject;
const prelisten = Module.prelisten;

@Module('db-init')
@autoinject('$db','$log')
class DatabaseInitializer {
  @prelisten // trigger db init on application startup
  doDbInit (ctx) {
    const self = this;
    // specify one or more providers to open connections to, or none
    // to open connections to all known DatabaseProviders.
    this.$db.scoped('mysql', async function (ctx) {
      // this async function behaves like koa middleware,
      // so feel free to await on promises!
      await self.createTables(ctx.transaction.mysql);
      await self.insertRows(ctx.transaction.mysql);
      // notice that this.transaction is identical to ctx.transaction
      // from @transaction! It's just a hash of open, named connections
      // to the DatabaseProviders specified.
    }).catch((err) => {
      self.$log.error(err.stack);
      process.exit(1); // in this case, we might want to kill our app if db init fails!
    });
  }

  /**
   * @returns {Promise}
   */
  createTables (mysqlConnection) { /* ... */ }

  /**
   * @returns {Promise}
   */
  insertRows (mysqlConnection) { /* ... */ }
}

module.exports = DatabaseInitializer;

Authentication Providers

View API docs 🕮

An AuthenticationProvider is a lightweight wrapper for a Passport provider library (such as passport-github) which performs all the complex set-up and configuration of the library automatically, and registers simple parameters which you must app.set (such as OAuth client ids and secrets). The purpose of AuthenticationProviders is to reduce boilerplate code between applications, and simplify often complex Passport configuration code. You may use as many different AuthenticationProviders as you wish in your application. Here's an example pulled from ravel-github-oauth2-provider:

Example Setup

app.js

const app = new require('ravel')();
const GitHubProvider = require('ravel-github-oauth2-provider');
new GitHubProvider(app);
// ... other providers and parameters
(async () => {
  await app.init();
});
// ... the rest of your Ravel app

Example Configuration

.ravelrc.json

{
  "github auth callback url" : "http://localhost:8080",
  "github auth path": "/auth/github",
  "github auth callback path": "/auth/github/callback",
  "github client id": "YOUR_CLIENT_ID",
  "github client secret" : "YOUR_CLIENT_SECRET"
}

You'll also need to implement an @authconfig module like this:

modules/authconfig.js

'use strict';

const Ravel = require('ravel');
const inject = Ravel.inject;
const Module = Ravel.Module;
const authconfig = Module.authconfig;

@authconfig
@Module('authconfig')
@inject('user-profiles')
class AuthConfig {
  constructor (userProfiles) {
    this.userProfiles = userProfiles;
  }
  serializeUser (profile) {
    // serialize profile to session using the id field
    return Promise.resolve(profile.id);
  }
  deserializeUser (id) {
    // retrieve profile from database using id from session
    return this.userProfiles.getProfile(id); // a Promise
  }
  verify (providerName, ...args) {
    // this method is roughly equivalent to the Passport verify callback, but
    // supports multiple simultaneous AuthenticationProviders.
    // providerName is the name of the provider which needs credentials verified
    // args is an array containing credentials, such as username/password for
    // verification against your database, or a profile and OAuth tokens. See
    // specific AuthenticationProvider library READMEs for more information about
    // how to implement this method.
  }
}

module.exports = AuthConfig;

List of Ravel AuthenticationProviders

Ravel currently supports several AuthenticationProviders via external libraries.

If you've written an AuthenticationProvider and would like to see it on this list, contact us or open an issue/PR against this README!

Authentication

View API docs 🕮

Once you've registered an AuthenticationProvider, requiring users to have an authenticated session to access a Routes or Resource endpoint is accomplished via the @authenticated decorator, which can be used at the class or method level:

Note: the @authenticated decorator works the same way on Routes and Resource classes/methods

const Routes = require('ravel').Routes;
const mapping = Routes.mapping;
const authenticated = Routes.authenticated;

@authenticated // protect all endpoints in this Routes class
@Routes('/')
class MyRoutes {
  @authenticated({redirect: true}) // protect one endpoint specifically
  @mapping(Routes.GET, 'app')
  async handler (ctx) {
    // will redirect to app.get('login route') if not signed in
  }
}

WebSockets

View API docs 🕮

Deployment and Scaling

Ravel is designed for horizontal scaling, and helps you avoid common pitfalls when designing your node.js backend application. In particular:

  • Session storage in Redis is highly recommended. Without it, you cannot safely replicate your Ravel app. When deploying multiple replicas of your Ravel app, be sure to app.set('redis host') to point to an external, shared redis server.
  • The internal koa application's app.proxy flag is set to true.
  • All Ravel dependencies are strictly locked (i.e. no use of ~ or ^ in package.json). This helps foster repeatability between members of your team, as well as between development/testing/production environments. Adherence to semver in the node ecosystem is unfortunately varied at best, so it is recommended that you follow the same practice in your app as well.
  • While it is possible to color outside the lines, Ravel provides a framework for developing stateless backend applications, where all stateful data is stored in external caches or databases.

It is strongly encouraged that you containerize your Ravel app using an Alpine-based docker container, and then explore technologies such as docker-compose or kubernetes to appropriately scale out and link to (at least) the official redis container. An example project with a reference docker-compose environment for Ravel can be found in the starter project.

Ravel apps may either be TLS-terminated by the proxy in front of them, or communicate with that proxy over TLS via app.set('https', true). When enabling https support, app.get('port') refers to the port which will now listen for https traffic. app.set('https options', {}) may be used in conjunction with options from here to set keys, certificates, etc. Ravel does not support exposing endpoints over http and https simultaneously, as it is recommended to exclusively use https via a terminating proxy or, if necessary, directly against the app.

Ravel does not explicitly require hiredis, but is is highly recommended that you install it alongside Ravel for improved redis performance.

If you are looking for a good way to share .ravelrc.json configuration between multiple replicas of the same Ravel app, have a look at ravel-etcd-config for easy distributed configuration.