Fage
Declarative sequential async middleware runner
Fage is an ultra-lightweight wrapper and function runner that enables composing apps as middleware and decouples the application interface from its implementation.
const myFageLogin = {
path: 'userLogin',
fns: [checkAuth, rateLimit, ctx => customLoginLogic(ctx.input)]
}
// Wire up Fage app
const app = Fage([myFageLogin, ...])
// Call it from your interface
express.post('/login', myMiddleware, (req, res) => app.userLogin(req.body, req.state.CustomAppData))
Using Fage (jump to the example):
-
Create logic blocks:
Define objects with a uniquepath
name and an array of "middleware" functionsfns
. These objects are your "method blocks". -
Package into functions:
These method blocks are bundled byFage(arrayOfMethodBlocks)
into a flat object of runnable functions, keyed by each method block'spath
value. Each function is a reducer that runs the middlewarefns
, passing each fn: a) thectx
context object and b) theoutput
of each call to the next function in the chain, returning a Promise that resolves as the final middleware output. -
Run functions:
Once bundled, functions are called with two params: a) untrusted data from userinput
, and b) trusted app/environment datameta
. These become available on thectx
context passed to Fage method block fns.
Fage functions can be invoked by independent interfaces that map their interface calls, inputs and application data to named Fage functions. This decouples the interface from the underlying app logic, which itself can be composed as middleware. Your app becomes a collection of lightweight objects that can be plugged into any interface (including HTTP, sockets, RPC, CLI etc).
Fage (fayj) - a phage is used to carry code for execution. It's also a f-unction c-age.
Getting Started:
Usage:
API:
Install
Fage is a private repository for which you will require a Github token.
TODO: Insert a howto for this
At which point you can install from Github as follows:
$ npm install likelytheory/fage
Overview
Write a Fage method block
const {nyanSay} = require('./mycode')
// Example middleware function
const myIsAuthedMiddleware = (ctx) => {
if (!ctx.meta.loggedIn) throw new Error('Not Logged In')
}
// Example Fage method block:
modules.export = {
path: 'nyanExclaim',
fns: [
myIsAuthedMiddleware, // throws if ctx.meta.loggedIn not set
(ctx) => ctx.input + '!@#!!',
(ctx, output) => (nyanSay(output), output) // returns output
]
}
Package into functions
Fage bundles this app logic into a flat object of runnable functions keyed by the path
:
const Fage = require('fage')
const nyanExclaim = require('./exampleAbove')
const app = Fage([nyanExclaim])
// -> {nyanExclaim: Function}
Run functions
Functions can then be run by passing fn(input, meta)
, where input
is the untrusted, raw user-input and meta
is an object comprising any system defined information (such as authentication details, environment data, etc).
await app.nyanExclaim('meow meow', {loggedIn: true})
// OR: await Fage.run(nyanExclaim, 'meow meow', {loggedIn: true})
// ,---/V\ ________________
// ,*'^`*.,*'^`*. ~|__(o.o) __/ meow meow!@#!! \
// .,*'^`*.,*'^`*.,*' UU UU `------------------`
// -> "meow meow!@#!!"
In the example above, failing to provide a loggedIn
value on the meta
parameter will trigger an Auth error in our method block:
await app.nyanExclaim('meow meow')
// -> Error: Not Logged In
Why this is cool
App logic can be composed as middleware. The bundled app is decoupled from any interface (making it very testable, and easy to reason about). This also allows you define arbitrary API maps for your interfaces, completely independently of your app logic. It gets even better when you use these maps to create generated interfaces (but the principle is still powerful even if you are manually wiring).
Using Fage methods
Once you have setup a Fage Application Object:
const app = Fage(methodBlocks)
Invoke named path
functions with app.<path>(input, meta)
params, which returns a Promise
that resolves as the output result or rejects as any thrown Error.
Fage Application Objects expose Fage methods that expect to be called with two "channel" parameters (both optional):
app.method(input, meta)
// -> Promise
Fage is intended to be invoked by a separate and independent interface (read more about interfaces here).
These interfaces should accept user input of some kind (input
), attach extra system or app derived data (meta
), and map their calls to an appropriate Fage method.
It's the interface's job to separate the data channels for Fage:
-
input
is any input data provided by the end user. -
meta
is any data that your interface sets (ie. trusted data)
For example: an HTTP interface might listen for a POST /hello
- when called (with say "world"
) it may first do auth token validation and set a few environment system values eg. {userId: null, turbo: false}
- the meta
channel is the mechanism for passing this application environment data to Fage. The interface would then call:
await app.hello("world", {userId: null, turbo: false})
// -> "hello world!"
The interface would then utilise the output of the Fage method ("hello world!
") however it wanted.
Method Blocks
A Fage Method Block is a simple object, mainly comprising a path
to uniquely identify the block and an array of fns
that are the middleware functions.
-
path
: String: Uniquely identifies the method block -
fns
: Array[Functions]: An array of Fage Middleware -
ref
: Object (Optional): Custom block data for use by middleware -
onError
: Function(ctx, err) (Optional): Hook to observe errors thrown by middleware
Notes for onError
In general, errors should be handled by your interface layer and not by Fage itself (which should simply generate errors to be handled).
However, the optional onError
hook is a function that is invoked if the method block throws an Error, and can be used to 'observe' (but not obstruct or 'catch') middlware failure states. The onError function receives two parameters: the ctx
context object and the thrown Error
object, eg. (ctx, err)
.
Note that onError
functions are run synchronously, any return values are discarded and any exceptions in the hook are silently suppressed, so only the original error is propagated.
Example Method Block For example (providing a bunch of code and middleware imported from elsewhere):
const example = {
path: 'superHacker', // -> app.superHacker(input, meta)
ref: {
model: inputModels.targetAndIntent, // Some custom model
nonsense: 'oh yes!', // Some farcical key
scopes: ['admin'] // Specify admin scopes
},
onError: (ctx, err) => errorLogHandler(err),
fns: [
mw.ensure.isAuthed, // Checks ctx.meta.user
mw.ensure.hasScopes, // Checks ctx.ref.scopes
mw.skematic.validate, // Checks ctx.input on ctx.ref.model
(ctx) => console.log(ctx.ref.nonsense), // "oh yes!"
(ctx) => hackThePlanet(ctx.input) // row, row, row ur boat
]
}
Middleware
Fage middleware are functions that accept two parameters mw(ctx, output)
, and optionally return an output. These middleware are what Fage chains together, passing the output of each previous function into the next.
Middleware functions can be either synchronous by immediately returning a value, or can be async by returning a Promise.
Fage waits on the output of each middleware before invoking the next in the chain.
Errors should throw and should be handled at the interface level - Fage methods should throw a descriptive Error object and leave the interface to determine how to handle this.
const checkMw = (ctx) => {
if (ctx.input === 'harold') throw new Error('No harolds!')
}
const sleepMw = (ctx) => sleepFor('30m').then(() => 'morning')
const logMw = (ctx, out) => console.log(`${out} ${ctx.input}!`)
const blk = {path: 'sleepy', fns: [checkMw, sleepMw, logMw]}
const app = Fage([blk])
await app.sleepy('harold')
// -> Error: No harolds!
await app.sleepy('jenny')
// (...after 30 mins...)
// "morning jenny!" (console.log output)
// -> undefined
Important note: The example above final return value was
undefined
- this is because the last middleware (logMw
) returned aconsole.log
, the return value of which isundefined
.Pay close attention to what you're returning.
Middleware have full access to the ctx
context object, detailed below.
ctx
context object
The context ctx
object is passed as the first parameter to every middleware.
The two data channels are available on ctx
as:
-
input
: Any - The "user supplied" input data channel -
meta
: Object - The application/interface set meta data channel
In addtion, underlying method block values are also provided:
-
path
: String - The uniquepath
value for the underlying method block -
ref
: Object - Anyref
data set in the underlying method block
The context object is immutable except for its state
parameter, which middleware may choose to use to store stateful info if returning its output is insufficient.
-
state
: Any - A mutable field to store data
API
The primary API for Fage is the single factory call Fage()
.
Fage(methodBlocksArray)
Packages the methodBlocks in methodBlocksArray
into a shallow object of runnable functions keyed by each method block's path
value.
const Fage = require('fage')
const mw = (ctx) => `hello ${ctx.input}!`
const greeterBlock = {path: 'hello', fns: [mw]}
const app = Fage([greeterBlock])
// -> {hello: Function}
await app.hello('world')
// -> "hello world!"
Parameters:
-
methodBlocksArray
: Array - An array of Fage method blocks
Returns:
-
Fage Application Object: Flat object of runnable functions keyed by each method block's
path
value.
Helper API methods There are also a handful of helper methods that may be of use during development of Fage apps:
Fage.run(methodBlock[, input, meta])
Runs a specific method block object.
Note: This is essentially what the factory
Fage()
method uses to bind a method block to run as a Function.
// Using the example from `Fage()` above
Fage.run(greeterBlock, 'earth')
// -> "hello earth!"
Parameters:
-
methodBlock
: MethodBlock Object - a Fage method block object -
input
: Any - (Optional) - any userland input data -
meta
: Object - (Optional) - application defined meta data
Returns:
-
Any: The final output of the method block's
fns
Interfaces
Fage simply bundles flat objects of runnable method blocks, which themselves are thin wrappers keyed by their path
values and containing middleware fns
. Fage methods receive parameters (input, meta)
, but Fage itself does not know (or care) where these come from or how they are defined.
That is the job of an interface.
An interface layer should:
- Provide endpoint access (http/socket/cli etc)
- (Optionally) Authorise requests (attaching results to
meta
) - (Optionally) Attach other system/app/user data to
meta
- Map the endpoint to a method
path
:app.<path>(input, meta)
- Handle errors thrown by Fage
- Return the
output
from Fage
- The
context
to run (object withpath
,model
,fns
etc) - Any user supplied
data
, and - Application
meta
information, including
Interfaces will typically have some knowledge of the shape of meta
data that the underlying Fage app requires (or vice versa). For example, if your interface does an authentication check and retrieves user information, it will attach these to meta
based on some key convention.
eg. If your Fage app looks for user login data on ctx.meta.user
, then your interface should be putting its userData under user
:
interface.endpoint(<path>, () => {
return await app.<path>(input, {user: userData})
})
Example
Here we setup a basic Fage app, and then create a basic Express HTTP interface.
Starting with the Fage app methods:
const Fage = require('fage')
const {chkPermissions, validateInputs, formatData, dbSave, dbGet, log} = require('./myCode')
// Fage method block
const createPost = {
path: 'postsCreate',
fns: [
ctx => chkPermissions(ctx.meta.scopes),
ctx => validateInputs(ctx.input),
ctx => formatData(ctx.input),
(ctx, formatted) => dbSave(formatted)
(ctx, created) => { log('posted', created.id); return created }
]
}
const getRandomPosts = {
path: 'postsGet',
fns: [ctx => dbGet('posts')]
}
// Bundle your Fage
const app = Fage([getPosts, createPost])
module.exports = app
And then writing up a very basic HTTP interface:
const express = require('express')
const {authenticator} = require('./myAuthCode')
const app = require('./fageBundle')
const server = new express()
server.use(authenticator) // Assume attaches `user` to `req.user`
// Create your endpoint logic
server.get('/posts', (req, res) => {
app.postsGet(null, {limit: 10, scopes: req.user.scopes})
.then(posts => res.send(posts))
})
server.post('/posts', (req, res) => {
app.postsCreate(req.body, {user: req.user})
.then(created => res.send(created))
})
server.listen(5000)
Development
Native to Node 6+
Written using Node 6+ compatible ES6, specfically to run natively (i.e. without needing transpilation). Note that if you are using async/await
notation in your app design, you will need to be running Node 7.6+.