koa-joi-router
Version:
Configurable, input validated routing for koa.
563 lines (472 loc) • 12.5 kB
JavaScript
;
const assert = require('assert');
const debug = require('debug')('koa-joi-router');
const isGenFn = require('is-gen-fn');
const flatten = require('flatten');
const methods = require('methods');
const KoaRouter = require('@koa/router');
const busboy = require('await-busboy');
const parse = require('co-body');
const Joi = require('joi');
const slice = require('sliced');
const delegate = require('delegates');
const clone = require('clone');
const OutputValidator = require('./output-validator');
module.exports = Router;
// expose Joi for use in applications
Router.Joi = Joi;
function Router() {
if (!(this instanceof Router)) {
return new Router();
}
this.routes = [];
this.router = new KoaRouter();
}
/**
* Array of routes
*
* Router.prototype.routes;
* @api public
*/
/**
* Delegate methods to internal router object
*/
delegate(Router.prototype, 'router')
.method('prefix')
.method('use')
.method('param');
/**
* Return koa middleware
* @return {Function}
* @api public
*/
Router.prototype.middleware = function middleware() {
return this.router.routes();
};
/**
* Adds a route or array of routes to this router, storing the route
* in `this.routes`.
*
* Example:
*
* var admin = router();
*
* admin.route({
* method: 'get',
* path: '/do/stuff/:id',
* handler: function *(next){},
* validate: {
* header: Joi object
* params: Joi object (:id)
* query: Joi object (validate key/val pairs in the querystring)
* body: Joi object (the request payload body) (json or form)
* maxBody: '64kb' // (json, x-www-form-urlencoded only - not stream size)
* // optional
* type: 'json|form|multipart' (required when body is specified)
* failure: 400 // http error code to use
* },
* meta: { // this is ignored but useful for doc generators etc
* desc: 'We can use this for docs generation.'
* produces: ['application/json']
* model: {} // response object definition
* }
* })
*
* @param {Object} spec
* @return {Router} self
* @api public
*/
Router.prototype.route = function route(spec) {
if (Array.isArray(spec)) {
for (let i = 0; i < spec.length; i++) {
this._addRoute(spec[i]);
}
} else {
this._addRoute(spec);
}
return this;
};
/**
* Adds a route to this router, storing the route
* in `this.routes`.
*
* @param {Object} spec
* @api private
*/
Router.prototype._addRoute = function addRoute(spec) {
this._validateRouteSpec(spec);
this.routes.push(spec);
debug('add %s "%s"', spec.method, spec.path);
const bodyParser = makeBodyParser(spec);
const specExposer = makeSpecExposer(spec);
const validator = makeValidator(spec);
const preHandlers = spec.pre ? flatten(spec.pre) : [];
const handlers = flatten(spec.handler);
const args = [
spec.path
].concat(preHandlers, [
prepareRequest,
specExposer,
bodyParser,
validator
], handlers);
const router = this.router;
spec.method.forEach((method) => {
router[method].apply(router, args);
});
};
/**
* Validate the spec passed to route()
*
* @param {Object} spec
* @api private
*/
Router.prototype._validateRouteSpec = function validateRouteSpec(spec) {
assert(spec, 'missing spec');
const ok = typeof spec.path === 'string' || spec.path instanceof RegExp;
assert(ok, 'invalid route path');
checkHandler(spec);
checkPreHandler(spec);
checkMethods(spec);
checkValidators(spec);
};
/**
* @api private
*/
function checkHandler(spec) {
if (!Array.isArray(spec.handler)) {
spec.handler = [spec.handler];
}
return flatten(spec.handler).forEach(isSupportedFunction);
}
/**
* @api private
*/
function checkPreHandler(spec) {
if (!spec.pre) {
return;
}
if (!Array.isArray(spec.pre)) {
spec.pre = [spec.pre];
}
return flatten(spec.pre).forEach(isSupportedFunction);
}
/**
* @api private
*/
function isSupportedFunction(handler) {
assert.equal('function', typeof handler, 'route handler must be a function');
if (isGenFn(handler)) {
throw new Error(`route handlers must not be GeneratorFunctions
Please use "async function" or "function".`);
}
}
/**
* Validate the spec.method
*
* @param {Object} spec
* @api private
*/
function checkMethods(spec) {
assert(spec.method, 'missing route methods');
if (typeof spec.method === 'string') {
spec.method = spec.method.split(' ');
}
if (!Array.isArray(spec.method)) {
throw new TypeError('route methods must be an array or string');
}
if (spec.method.length === 0) {
throw new Error('missing route method');
}
spec.method.forEach((method, i) => {
assert(typeof method === 'string', 'route method must be a string');
spec.method[i] = method.toLowerCase();
});
}
/**
* Validate the spec.validators
*
* @param {Object} spec
* @api private
*/
function checkValidators(spec) {
if (!spec.validate) return;
let text;
if (spec.validate.body) {
text = 'validate.type must be declared when using validate.body';
assert(/json|form/.test(spec.validate.type), text);
}
if (spec.validate.type) {
text = 'validate.type must be either json, form, multipart or stream';
assert(/json|form|multipart|stream/i.test(spec.validate.type), text);
}
if (spec.validate.output) {
spec.validate._outputValidator = new OutputValidator(spec.validate.output);
}
// default HTTP status code for failures
if (!spec.validate.failure) {
spec.validate.failure = 400;
}
}
/**
* Does nothing
* @param {[type]} ctx [description]
* @param {Function} next [description]
* @return {async function} [description]
* @api private
*/
async function noopMiddleware(ctx, next) {
return await next();
}
/**
* Handles parser internal errors
* @param {Object} spec [description]
* @param {function} parsePayload [description]
* @return {async function} [description]
* @api private
*/
function wrapError(spec, parsePayload) {
return async function errorHandler(ctx, next) {
try {
await parsePayload(ctx);
} catch (err) {
captureError(ctx, 'type', err);
if (!spec.validate.continueOnError) {
return ctx.throw(err);
}
}
await next();
};
}
/**
* Creates JSON body parser middleware.
*
* @param {Object} spec
* @return {async function}
* @api private
*/
function makeJSONBodyParser(spec) {
const opts = spec.validate.jsonOptions || {};
if (typeof opts.limit === 'undefined') {
opts.limit = spec.validate.maxBody;
}
return async function parseJSONPayload(ctx) {
if (!ctx.request.is('json')) {
return ctx.throw(400, 'expected json');
}
// eslint-disable-next-line require-atomic-updates
ctx.request.body = ctx.request.body || await parse.json(ctx, opts);
};
}
/**
* Creates form body parser middleware.
*
* @param {Object} spec
* @return {async function}
* @api private
*/
function makeFormBodyParser(spec) {
const opts = spec.validate.formOptions || {};
if (typeof opts.limit === 'undefined') {
opts.limit = spec.validate.maxBody;
}
return async function parseFormBody(ctx) {
if (!ctx.request.is('urlencoded')) {
return ctx.throw(400, 'expected x-www-form-urlencoded');
}
// eslint-disable-next-line require-atomic-updates
ctx.request.body = ctx.request.body || await parse.form(ctx, opts);
};
}
/**
* Creates stream/multipart-form body parser middleware.
*
* @param {Object} spec
* @return {async function}
* @api private
*/
function makeMultipartParser(spec) {
const opts = spec.validate.multipartOptions || {};
if (typeof opts.autoFields === 'undefined') {
opts.autoFields = true;
}
return async function parseMultipart(ctx) {
if (!ctx.request.is('multipart/*')) {
return ctx.throw(400, 'expected multipart');
}
ctx.request.parts = busboy(ctx, opts);
};
}
/**
* Creates body parser middleware.
*
* @param {Object} spec
* @return {async function}
* @api private
*/
function makeBodyParser(spec) {
if (!(spec.validate && spec.validate.type)) return noopMiddleware;
switch (spec.validate.type) {
case 'json':
return wrapError(spec, makeJSONBodyParser(spec));
case 'form':
return wrapError(spec, makeFormBodyParser(spec));
case 'stream':
case 'multipart':
return wrapError(spec, makeMultipartParser(spec));
default:
throw new Error(`unsupported body type: ${spec.validate.type}`);
}
}
/**
* @api private
*/
function captureError(ctx, type, err) {
// expose Error message to JSON.stringify()
err.msg = err.message;
if (!ctx.invalid) ctx.invalid = {};
ctx.invalid[type] = err;
}
/**
* Creates validator middleware.
*
* @param {Object} spec
* @return {async function}
* @api private
*/
function makeValidator(spec) {
const props = 'header query params body'.split(' ');
return async function validator(ctx, next) {
if (!spec.validate) return await next();
let err;
for (let i = 0; i < props.length; ++i) {
const prop = props[i];
if (spec.validate[prop]) {
err = validateInput(prop, ctx, spec.validate);
if (err) {
captureError(ctx, prop, err);
if (!spec.validate.continueOnError) return ctx.throw(err);
}
}
}
await next();
if (spec.validate._outputValidator) {
debug('validating output');
err = spec.validate._outputValidator.validate(ctx);
if (err) {
err.status = 500;
return ctx.throw(err);
}
}
};
}
/**
* Exposes route spec
* @param {Object} spec The route spec
* @returns {async Function} Middleware
* @api private
*/
function makeSpecExposer(spec) {
const defn = clone(spec);
return async function specExposer(ctx, next) {
ctx.state.route = defn;
await next();
};
}
/**
* Middleware which creates `request.params`.
*
* @api private
*/
async function prepareRequest(ctx, next) {
ctx.request.params = ctx.params;
await next();
}
/**
* Validates request[prop] data with the defined validation schema.
*
* @param {String} prop
* @param {koa.Request} request
* @param {Object} validate
* @returns {Error|undefined}
* @api private
*/
function validateInput(prop, ctx, validate) {
debug('validating %s', prop);
const request = ctx.request;
const res = Joi.compile(validate[prop]).validate(request[prop], validate.validateOptions || {});
if (res.error) {
res.error.status = validate.failure;
return res.error;
}
// update our request w/ the casted values
switch (prop) {
case 'header': // request.header is getter only, cannot set it
case 'query': // setting request.query directly causes casting back to strings
Object.keys(res.value).forEach((key) => {
request[prop][key] = res.value[key];
});
break;
case 'params':
request.params = ctx.params = res.value;
break;
default:
request[prop] = res.value;
}
}
/**
* Routing shortcuts for all HTTP methods
*
* Example:
*
* var admin = router();
*
* admin.get('/user', async function(ctx) {
* ctx.body = ctx.session.user;
* })
*
* var validator = Joi().object().keys({ name: Joi.string() });
* var config = { validate: { body: validator }};
*
* admin.post('/user', config, async function(ctx){
* console.log(ctx.body);
* })
*
* async function commonHandler(ctx){
* // ...
* }
* admin.post('/account', [commonHandler, async function(ctx){
* // ...
* }]);
*
* @param {String} path
* @param {Object} [config] optional
* @param {async function|async function[]} handler(s)
* @return {App} self
*/
methods.forEach((method) => {
method = method.toLowerCase();
Router.prototype[method] = function(path) {
// path, handler1, handler2, ...
// path, config, handler1
// path, config, handler1, handler2, ...
// path, config, [handler1, handler2], handler3, ...
let fns;
let config;
if (typeof arguments[1] === 'function' || Array.isArray(arguments[1])) {
config = {};
fns = slice(arguments, 1);
} else if (typeof arguments[1] === 'object') {
config = arguments[1];
fns = slice(arguments, 2);
}
const spec = {
path: path,
method: method,
handler: fns
};
Object.assign(spec, config);
this.route(spec);
return this;
};
});