fmbaucis
Version:
Build scalable REST APIs using the open source tools and standards you already know.
174 lines (161 loc) • 6.63 kB
JavaScript
// __Dependencies__
var express = require('express');
var util = require('util');
var es = require('event-stream');
var BaucisError = require('../../BaucisError');
// __Private Module Members__
var validOperators = [ '$set', '$push', '$pull' ];
// __Module Definition__
var decorator = module.exports = function (options, protect) {
var controller = this;
function checkBadUpdateOperatorPaths (operator, paths) {
var bad = false;
var whitelisted = controller.operators(operator);
var parts;
if (!whitelisted) return true;
parts = whitelisted.split(/\s+/);
paths.forEach(function (path) {
if (parts.indexOf(path) !== -1) return;
bad = true;
});
return bad;
}
// If there's a body, send it through any user-added streams.
controller.query('instance', 'put', function (request, response, next) {
var parser;
var count = 0;
var operator = request.headers['x-baucis-update-operator'];
var versionKey = controller.schema().get('versionKey');
var pipeline = protect.pipeline();
// Check if the body was parsed by some external middleware e.g. `express.json`.
// If so, create a one-document stream from the parsed body.
if (request.body) {
pipeline(es.readArray([ request.body ]));
}
// Otherwise, stream and parse the request.
else {
parser = request.baucis.api.parser(request.get('content-type'));
if (!parser) return next(BaucisError.UnsupportedMediaType());
pipeline(request);
pipeline(parser);
}
// Set up the stream context.
pipeline(function (body, callback) {
var context = { doc: undefined, incoming: body };
callback(null, context);
});
// Load the Mongoose document and add it to the context, unless this is a
// special update operator.
if (!operator) {
pipeline(function (context, callback) {
var query = controller.model().findOne(request.baucis.conditions);
query.exec(function (error, doc) {
if (error) return callback(error);
if (!doc) return callback(BaucisError.NotFound());
// Add the Mongoose document to the context.
callback(null, { doc: doc, incoming: context.incoming });
});
});
}
// Pipe through user streams, if any.
pipeline(request.baucis.incoming());
// If the document ID is present, ensure it matches the ID in the URL.
pipeline(function (context, callback) {
var bodyId = context.incoming[controller.findBy()];
if (bodyId === undefined) return callback(null, context);
if (bodyId === request.params.id) return callback(null, context);
callback(BaucisError.BadRequest("The ID of the update document did not match the URL's document ID"));
});
// Ensure the request includes a finite object version if locking is enabled.
if (controller.locking()) {
pipeline(function (context, callback) {
var updateVersion = context.incoming[versionKey];
if (updateVersion === undefined || !Number.isFinite(Number(updateVersion))) {
return callback(BaucisError.BadRequest('Locking is enabled, so the target version must be provided in the request body using path "%s"', versionKey));
}
callback(null, context);
});
// Add some locking checks only applicable to the default update operator.
if (!operator) {
// Make sure the version key was selected.
pipeline(function (context, callback) {
if (!context.doc.isSelected(versionKey)) {
callback(BaucisError.BadRequest('The version key "%s" must be selected', versionKey));
return;
}
// Pass through.
callback(null, context);
});
pipeline(function (context, callback) {
var updateVersion = Number(context.incoming[versionKey]);
// Update and current version have been found. Check if they're equal.
if (updateVersion !== context.doc[versionKey]) return callback(BaucisError.LockConflict());
// One is not allowed to set __v and increment in the same update.
delete context.incoming[versionKey];
context.doc.increment();
// Pass through.
callback(null, context);
});
}
}
// Ensure there is exactly one update document.
pipeline(es.through(
function (context) {
count += 1;
if (count === 2) {
next(BaucisError.BadRequest('The request body contained more than one update document'));
return;
}
if (count > 1) return;
this.emit('data', context);
},
function () {
if (count === 0) {
next(BaucisError.BadRequest('The request body did not contain an update document'));
}
this.emit('end');
}
));
// Finish up for the default update operator.
if (!operator) {
// Update the Mongoose document with the request body.
pipeline(function (context, callback) {
context.doc.set(context.incoming);
// Pass through.
callback(null, context);
});
// Save the Mongoose document.
pipeline(function (context, callback) { context.doc.save(callback); });
}
// Finish up for a non-default update operator (bypasses validation).
else {
pipeline(function (context, callback) {
var wrapper = {};
if (validOperators.indexOf(operator) === -1) {
callback(BaucisError.BadRequest('The requested update operator "%s" is not supported', operator));
return;
}
// Ensure that some paths have been enabled for the operator.
if (!controller.operators(operator)) {
callback(BaucisError.Forbidden('The requested update operator "%s" is not enabled for this resource', operator));
return;
}
// Make sure paths have been whitelisted for this operator.
if (checkBadUpdateOperatorPaths(operator, Object.keys(context.incoming))) {
callback(BaucisError.Forbidden('This update path is forbidden for the requested update operator "%s"', operator));
return;
}
wrapper[operator] = context.incoming;
if (controller.locking()) {
request.baucis.conditions[versionKey] = Number(context.incoming[versionKey]);
}
// Update the doc using the supplied operator and bypassing validation.
controller.model().update(request.baucis.conditions, wrapper, callback);
});
}
var s = pipeline();
s.on('end', next);
s.on('error', next);
s.resume();
});
};