service-template-node
Version:
A blueprint for MediaWiki REST API services
276 lines (246 loc) • 8.76 kB
JavaScript
;
const BBPromise = require('bluebird');
const preq = require('preq');
const express = require('express');
const uuidv1 = require('uuid/v1');
const bunyan = require('bunyan');
/**
* Error instance wrapping HTTP error responses
*/
class HTTPError extends Error {
constructor(response) {
super();
Error.captureStackTrace(this, HTTPError);
if (response.constructor !== Object) {
// just assume this is just the error message
response = {
status: 500,
type: 'internal_error',
title: 'InternalError',
detail: response
};
}
this.name = this.constructor.name;
this.message = `${response.status}`;
if (response.type) {
this.message += `: ${response.type}`;
}
Object.assign(this, response);
}
}
/**
* Generates an object suitable for logging out of a request object
* @param {!Request} req the request
* @param {?RegExp} whitelistRE the RegExp used to filter headers
* @return {!Object} an object containing the key components of the request
*/
function reqForLog(req, whitelistRE) {
const ret = {
url: req.originalUrl,
headers: {},
method: req.method,
params: req.params,
query: req.query,
body: req.body,
remoteAddress: req.connection.remoteAddress,
remotePort: req.connection.remotePort
};
if (req.headers && whitelistRE) {
Object.keys(req.headers).forEach((hdr) => {
if (whitelistRE.test(hdr)) {
ret.headers[hdr] = req.headers[hdr];
}
});
}
return ret;
}
/**
* Serialises an error object in a form suitable for logging.
* @param {!Error} err error to serialise
* @return {!Object} the serialised version of the error
*/
function errForLog(err) {
const ret = bunyan.stdSerializers.err(err);
ret.status = err.status;
ret.type = err.type;
ret.detail = err.detail;
// log the stack trace only for 500 errors
if (Number.parseInt(ret.status, 10) !== 500) {
ret.stack = undefined;
}
return ret;
}
/**
* Wraps all of the given router's handler functions with
* promised try blocks so as to allow catching all errors,
* regardless of whether a handler returns/uses promises
* or not.
* @param {!Object} route the object containing the router and path to bind it to
* @param {!Application} app the application object
*/
function wrapRouteHandlers(route, app) {
route.router.stack.forEach((routerLayer) => {
let path = (route.path + routerLayer.route.path.slice(1))
.replace(/\/:/g, '/--')
.replace(/^\//, '')
.replace(/[/?]+$/, '');
path = app.metrics.normalizeName(path || 'root');
routerLayer.route.stack.forEach((layer) => {
const origHandler = layer.handle;
layer.handle = (req, res, next) => {
const startTime = Date.now();
BBPromise.try(() => origHandler(req, res, next))
.catch(next)
.finally(() => {
let statusCode = parseInt(res.statusCode, 10) || 500;
if (statusCode < 100 || statusCode > 599) {
statusCode = 500;
}
const statusClass = `${Math.floor(statusCode / 100)}xx`;
const stat = `${path}.${req.method}.`;
app.metrics.endTiming([
stat + statusCode,
stat + statusClass,
`${stat}ALL`
], startTime);
});
};
});
});
}
/**
* Generates an error handler for the given applications and installs it.
* @param {!Application} app the application object to add the handler to
*/
function setErrorHandler(app) {
app.use((err, req, res, next) => {
let errObj;
// ensure this is an HTTPError object
if (err.constructor === HTTPError) {
errObj = err;
} else if (err instanceof Error) {
// is this an HTTPError defined elsewhere? (preq)
if (err.constructor.name === 'HTTPError') {
const o = { status: err.status };
if (err.body && err.body.constructor === Object) {
Object.keys(err.body).forEach((key) => {
o[key] = err.body[key];
});
} else {
o.detail = err.body;
}
o.message = err.message;
errObj = new HTTPError(o);
} else {
// this is a standard error, convert it
errObj = new HTTPError({
status: 500,
type: 'internal_error',
title: err.name,
detail: err.detail || err.message,
stack: err.stack
});
}
} else if (err.constructor === Object) {
// this is a regular object, suppose it's a response
errObj = new HTTPError(err);
} else {
// just assume this is just the error message
errObj = new HTTPError({
status: 500,
type: 'internal_error',
title: 'InternalError',
detail: err
});
}
// ensure some important error fields are present
errObj.status = errObj.status || 500;
errObj.type = errObj.type || 'internal_error';
// add the offending URI and method as well
errObj.method = errObj.method || req.method;
errObj.uri = errObj.uri || req.url;
// some set 'message' or 'description' instead of 'detail'
errObj.detail = errObj.detail || errObj.message || errObj.description || '';
// adjust the log level based on the status code
let level = 'error';
if (Number.parseInt(errObj.status, 10) < 400) {
level = 'trace';
} else if (Number.parseInt(errObj.status, 10) < 500) {
level = 'info';
}
// log the error
const component = (errObj.component ? errObj.component : errObj.status);
(req.logger || app.logger).log(`${level}/${component}`, errForLog(errObj));
// let through only non-sensitive info
const respBody = {
status: errObj.status,
type: errObj.type,
title: errObj.title,
detail: errObj.detail,
method: errObj.method,
uri: errObj.uri
};
res.status(errObj.status).json(respBody);
});
}
/**
* Creates a new router with some default options.
* @param {?Object} [opts] additional options to pass to express.Router()
* @return {!Router} a new router object
*/
function createRouter(opts) {
const options = {
mergeParams: true
};
if (opts && opts.constructor === Object) {
Object.assign(options, opts);
}
return new express.Router(options);
}
/**
* Adds logger to the request and logs it.
* @param {!*} req request object
* @param {!Application} app application object
*/
function initAndLogRequest(req, app) {
req.headers = req.headers || {};
req.headers['x-request-id'] = req.headers['x-request-id'] || uuidv1();
req.logger = app.logger.child({
request_id: req.headers['x-request-id'],
request: reqForLog(req, app.conf.log_header_whitelist)
});
req.context = { reqId: req.headers['x-request-id'] };
req.issueRequest = (request) => {
if (!(request.constructor === Object)) {
request = { uri: request };
}
if (request.url) {
request.uri = request.url;
delete request.url;
}
if (!request.uri) {
return BBPromise.reject(new HTTPError({
status: 500,
type: 'internal_error',
title: 'No request to issue',
detail: 'No request has been specified'
}));
}
request.method = request.method || 'get';
request.headers = request.headers || {};
Object.assign(request.headers, {
'user-agent': app.conf.user_agent,
'x-request-id': req.context.reqId
});
req.logger.log('trace/req', { msg: 'Outgoing request', out_request: request });
return preq(request);
};
req.logger.log('trace/req', { msg: 'Incoming request' });
}
module.exports = {
HTTPError,
initAndLogRequest,
wrapRouteHandlers,
setErrorHandler,
router: createRouter
};