@axway/api-builder-runtime
Version:
API Builder Runtime
431 lines (420 loc) • 14.3 kB
JavaScript
/**
* @class APIBuilder.Middleware
*/
const express = require('express'),
path = require('path'),
fs = require('fs'),
async = require('async'),
crypto = require('crypto'),
cookieParser = require('cookie-parser'),
bodyParser = require('body-parser'),
compression = require('compression'),
sessions = require('client-sessions'),
busboy = require('connect-busboy'),
_ = require('lodash'),
APIBuilder = require('./apibuilder'),
apiBuilderConfig = require('@axway/api-builder-config'),
tmpdir = require('os').tmpdir();
function Middleware(app, dirname, sessionConfig, config = {}) {
this.app = app;
app.set('x-powered-by', false);
// sets the body-parser limit for maximum request body size
var limit = (config.bodyParser || {}).limit || '1mb';
var secret = config.cookieSecret;
app.use(cookieParser(secret));
app.use((req, resp, next) => {
resp.setHeader('Start-Time', `${Date.now()}`);
next();
});
app.use(bodyParser.text({
limit,
type: [
'text/*',
'application/xml',
'application/xml-dtd',
'application/xml-external-parsed-entity',
'application/*+xml',
'image/svg+xml',
'model/x3d+xml'
]
}));
app.use(bodyParser.json({
limit,
type: [ 'application/json', 'application/*+json' ]
}));
app.use(bodyParser.urlencoded({ extended: false, limit }));
// Handles multipart/form-data
// Busboy is bypassed for GET and HEAD requests as well as non-multipart
// or form data requests
app.use(busboy(config.busboy || {}));
// Handle streaming multipart file uploads to disk and any per-part limits,
// and non-file multipart fields
app.use(function (req, resp, next) {
if (req.busboy && req.headers['content-type']
&& req.headers['content-type'].indexOf('multipart/form-data') === 0) {
req.files = {};
req.fields = {};
let total = 0;
let count = 0;
let finished;
let sent = false;
req.busboy.on('file', function (fieldname, file, info) {
req.logger.trace('multipart file event:', { fieldname, info });
file.on('limit', () => {
req.logger.trace('multipart limit reached:', fieldname);
if (sent) {
return;
}
// Multipart part size limit hit
// Prevent middleware from continuing and send 413 response
sent = true;
onMultipartLimit(req, resp, config);
});
// Write a unique file to disk
const tmpfileName = `${req.getId()}_${total}_${fieldname}_${info.filename || ''}`;
const tmpfile = path.join(tmpdir, tmpfileName);
total++;
req.files[fieldname] = {
file: tmpfile,
filename: info.filename,
encoding: info.encoding,
mimetype: info.mimeType
};
const stream = fs.createWriteStream(tmpfile);
req.logger.debug('multipart file dest:', fieldname, tmpfile);
// handle stream error here, for example,
// the file could not be created(eg. disk full)
stream.on('error', err => {
req.logger.error('multipart file error:', fieldname, tmpfile, err);
req.unpipe(stream);
if (sent) {
return;
}
sent = true;
resp.status(500).send({
success: false,
code: 500,
message: 'Server Error'
});
});
stream.on('close', function () {
req.logger.trace('multipart file closed:', fieldname, tmpfile);
// we really hope that the last stream closes after busboy finishes
// this whole middleware can be refactored to guarantee this
if (++count === total && finished) {
total = -1;
req.logger.trace('multipart files received:', req.files);
// Prevent next being called if the request already ended
if (!sent) {
process.nextTick(next);
}
}
});
file.pipe(stream);
});
req.busboy.on('field', (fieldname, value, info) => {
req.logger.trace('multipart field event:', { fieldname, value, info });
if (sent) {
return;
}
if (info.valueTruncated) {
// Multipart part size limit hit
// Prevent middleware from continuing and send 413 response
sent = true;
onMultipartLimit(req, resp, config);
return;
}
req.fields[fieldname] = { value };
});
req.busboy.on('finish', () => {
req.logger.trace('multipart finished:', {
files: Object.keys(req.files).length,
fields: Object.keys(req.fields).length
});
finished = true;
// the request could have already ended (with 413) before busboy finished
// in this case, don't continue to the next middleware.
if (!sent && total === 0) {
// there's no files being uploaded so call next.
// if there are files then once the last one finishes writing, next
// will be called when the last file stream closes instead.
process.nextTick(next);
}
});
req.busboy.on('error', err => {
const genErr = 'Failed to parse multipart/form-data body';
req.logger.error(genErr, err);
req.unpipe(req.busboy);
if (sent) {
return;
}
sent = true;
// set the response status to 400 only for multipart form data
resp.status(400).send({
success: false,
code: 400,
message: genErr
});
});
// If the request is terminated early, be sure to clean up because
// after won't be emitted.
const deleteRequestFiles = () => {
// Timeout is in place because this after is emitted before the
// request terminates. file uploads could actually still be in
// operation (i.e. busboy could emit a file event after)
setTimeout(function () {
async.each(req.files, function (entry, cb) {
req.logger.trace('multipart removing file:', entry.file);
fs.unlink(entry.file, cb);
});
req.files = null;
}, 500);
};
req.once('close', deleteRequestFiles);
app.once('after', deleteRequestFiles);
req.pipe(req.busboy);
} else {
next();
}
});
app.use(bodyParser.raw({
limit,
// bodyParser will set the Buffer to the body if the "type" function
// returns a truthy value.
type: req => {
const contentType = req.header('content-type');
// don't process the body when content type is missing.
// multipart/form-data content type is handled by busboy so we use
// the same regex that busboy is using to detect supported types.
const isHandledByBusboy = /^multipart\/form-data/i.test(contentType);
if (!contentType || isHandledByBusboy) {
return false;
}
return true;
}
}));
app.use(function (req, resp, next) {
if (req.body && _.isObject(req.body) && !(req.body instanceof Buffer)) {
// we add to _params because express changes for each next
req.params = req._params = _.merge(req.params, req.body);
} else {
req._params = req.params;
}
req.files && Object.keys(req.files).forEach(function (name) {
req.params[name] = req.files[name].file;
});
req.fields && Object.keys(req.fields).forEach(function (name) {
req.params[name] = req.fields[name].value;
});
next();
});
if (apiBuilderConfig.flags.enableStrictBodyPayloads) {
app.use(function (req, resp, next) {
if (parseInt(req.headers['content-length'], 10) > 0
&& [ 'GET', 'HEAD' ].includes(req.method)) {
// setting these prevents swagger-tools from handling the body in
// swagger-metadata because the various middleware for parsing body
// checks to see that these are undefined.
req.files = {};
req.body = {};
req.logger.warn('Request with GET/HEAD method cannot have body, the body will be discarded.');
}
next();
});
}
app.use(compression());
app.on('after', function (req) {
if (req && req.res) {
req.res.req = null;
}
});
if (sessionConfig && sessionConfig.encryptionKey && sessionConfig.signatureKey) {
setupSession(app, sessionConfig);
}
var writeHead = express.response._originalWriteHead || express.response.writeHead;
if (!express.response._originalWriteHead) {
express.response._originalWriteHead = writeHead;
}
express.response.writeHead = function () {
// if the user by passes this class and does their own writeHead, end, etc.
// we need to catch it since it's out of order the way we do it in response.js
// so just flag it and when we see this below, just assume we're good to go
var called = this.bodyFlushed;
this.bodyFlushed = this._flushBodyCalled = true;
writeHead.apply(this, arguments);
if (!called) {
// if we get here it's because we're doing a non-API Route
// in which case we need to trigger after event
app.emit('after', this.req, this.req.res);
}
};
// called by the response.js
express.response._flushBody = function (content) {
if (this.bodyFlushed) {
return;
}
addEndHeaders(this.req, this, content);
this.bodyFlushed = this._flushBodyCalled = true;
this.end(content);
// for us to log the response body, we have to store it.
this.req.res.body = content;
app.emit('after', this.req, this.req.res);
};
var setHeader = express.response._originalSetHeader || express.response.setHeader;
if (!express.response._originalSetHeader) {
express.response._originalSetHeader = setHeader;
}
express.response.setHeader = function (k, v) {
this._headers = this._headers || {};
if (v) {
setHeader.apply(this, arguments);
this._headers[k.toLowerCase()] = v;
} else {
this.removeHeader(k);
}
};
var removeHeader = express.response._originalRemoveHeader || express.response.removeHeader;
if (!express.response._originalRemoveHeader) {
express.response._originalRemoveHeader = removeHeader;
}
express.response.removeHeader = function (k) {
removeHeader.apply(this, arguments);
if (this._headers) {
delete this._headers[k.toLowerCase()];
}
};
express.response.headers = function () {
return this._headers;
};
Object.defineProperty(express.response, 'contentType', {
set: function (value) {
this.setHeader('Content-Type', value);
},
get: function () {
return this._headers['content-type'] || this._headers['Content-Type'];
}
});
var render = express.response._originalRender || express.response.render;
if (!express.response._originalRender) {
express.response._originalRender = render;
}
express.response.render = function (name, locals) {
if (locals && _.isObject(locals)) {
// make sure that we render any ORM objects as JSON instead of their real classes
locals = _.cloneDeepWith(locals, objectCustomizer);
}
return render.apply(this, arguments);
};
}
/**
* Handles a multipart limit when it occurs and returns a 413 response.
* @param {object} req - request object
* @param {object} resp - response object
* @param {object} config - api builder config
*/
function onMultipartLimit(req, resp, config) {
if (config.limits.multipartPartSize !== Infinity) {
// It's possible to hit here if config.busboy is configured independently.
// Can remove this check when we remove the deprecated config.busboy
req.logger.info(`Multipart limit hit (${config.limits.multipartPartSize}B)`);
}
resp.status(413).send({
success: false,
code: 413,
'request-id': req.getId(),
message: 'Payload too large'
});
}
/*
* Wrap response.send to add end headers.
*/
var expressResponseSend = express.response.send;
express.response.send = function wrappedResponseSend(content) {
if (_.isNumber(arguments[0])) {
throw new Error('don\'t use resp.send with number. call resp.status instead');
}
addEndHeaders(this.req, this, content);
expressResponseSend.call(this, content);
};
/*
* convert our value into a valid JSON object
*/
function objectCustomizer(value) {
if (value === undefined || value === null) {
return value;
}
// return serialized JSON versions of our ORM data objects
if (value instanceof APIBuilder.Instance
|| value instanceof APIBuilder.Collection
|| _.isFunction(value.toJSON)) {
return value.toJSON();
} else if (Array.isArray(value)) {
var array = [];
value.forEach(function (o) {
array.push(_.cloneDeepWith(o, objectCustomizer));
});
return array;
} /* don't serialize functions*/ else if (_.isFunction(value)) {
return undefined;
} else if (_.isObject(value)) {
var obj = {};
Object.keys(value).forEach(function (k) {
var v = _.cloneDeepWith(value[k], objectCustomizer);
obj[k] = v;
});
return obj;
}
return value;
}
function addEndHeaders(req, resp, content) {
const startTime = resp.getHeader('start-time');
if (startTime) {
const started = parseInt(startTime, 10);
resp.setHeader('Response-Time', `${Date.now() - started}`);
}
// req.server is undefined for both healthcheck APIs and requests that happen
// when the server is shutting down.
if (content && req.server && req.server.config.http.headers['content-md5']) {
const hash = crypto.createHash('md5');
// REFACTOR: Has issues if content is object ([Object object]). Consider a future
// breaking change to support more types i.e. https://github.com/puleos/object-hash
// Also entirely broken for legacy APIs as the response contains the request ID im
// the body, causing a new hash on every response.
hash.update(String(content));
resp.setHeader('Content-MD5', hash.digest('hex'));
}
}
function setupSession(app, sessionConfig) {
app.use(
sessions({
// load from configuration
encryptionAlgorithm: sessionConfig.encryptionAlgorithm || 'aes256',
encryptionKey: Buffer.from(sessionConfig.encryptionKey, 'base64'),
signatureAlgorithm: sessionConfig.signatureAlgorithm || 'sha512-drop256',
signatureKey: Buffer.from(sessionConfig.signatureKey, 'base64'),
// cookie name dictates the key name added to the request object - do not changed this
// or you will get an error
cookieName: 'APIBuilder',
// should be a large unguessable string
secret: sessionConfig.secret || crypto.randomBytes(24).toString('base64'),
// how long the session will stay valid in ms
duration: sessionConfig.duration || 24 * 60 * 60 * 1000,
// if expiresIn < activeDuration, the session will be extended by activeDuration
// milliseconds
activeDuration: sessionConfig.activeDuration || 1000 * 60 * 5,
cookie: sessionConfig.cookie || {}
})
);
app.use(function sessionMapper(req, resp, next) {
// set the session value to the name of our sessions object
req.session = req.APIBuilder;
// alias destroy to reset
req.session.destroy = req.APIBuilder.reset;
try {
next();
} catch (e) {
// Ignore errors
}
});
}
module.exports = Middleware;