I had been programming a filters setup for the node API of a MEAN stack app.
Having this ´User´ model:
// user.model.js (complete)
var mongoose = require('mongoose');
var schema = new mongoose.Schema({
name: String,
admin: Boolean
});
module.exports = mongoose.model('User', schema);
It allowed a ´User´ controller like this:
// user.controller.js (complete)
var fields = [
'name',
function (admin) {
return !!admin.length;
}
];
var Item = require('./user.model');
var Controller = require(global.absPath + '/app/shared/CRUD.controller');
module.exports = Controller(Item, fields);
The meaning should be straightforward: copy the ´name´ field as is and make the ´admin´ field a proper boolean. That was made possible by this:
// CRUD.controller.js (excerpt)
module.exports = CRUD_Controller;
function CRUD_Controller(Item, fields) {
//...
function Create(req, res) {
var item = new Item();
CopyFields(fields, req.body, item);
item.save(function(err) {
if (err) {
return res.send(err);
}
res.json({
message: 'Item created!'
});
});
}
function CopyFields(fields, data, item) {
(fields || []).forEach(function(field) {
switch (typeof field) {
case 'string':
item[field] = data[field];
break;
case 'function':
var matches = String(field).match(/^functions*(s*(w+)s*)/);
if (!(matches && matches[1])) {
console.log('Expected a function with only one argument.');
return;
}
var name = matches[1];
item[name] = field(data[name]);
break;
}
});
}
//...
}
Then I wanted to add a ´password´ field to the ´User´ model. For storing it I decided to go with Strong Password Hashing with Node.js Standard Library. Properly translated to JavaScript and slightly tweaked I got this:
// hash.js (complete)
var crypto = require('crypto');
module.exports = Hash;
return;
function Hash(options, callback) {
// Default options.plaintext to a random 8-character string
if (!options.plaintext) {
return crypto.randomBytes(8, function(err, buf) {
if (err) {
return callback(err);
}
options.plaintext = buf.toString('base64');
Hash(options, callback);
});
}
// Default options.salt to a random 64-character string (512 bits)
if (!options.salt) {
return crypto.randomBytes(64, function(err, buf) {
if (err) {
return callback(err);
}
options.salt = buf.toString('base64');
Hash(options, callback);
});
}
// Default options.iterations to 10k
if (!options.iterations) {
options.iterations = 10000;
}
// Default options.digest to sha1
if (!options.digest) {
options.digest = 'sha1';
}
crypto.pbkdf2(options.plaintext, options.salt, options.iterations, 64, options.digest, function(err, key) {
if (err) {
return callback(err);
}
options.algorithm = 'PBDFK2';
options.key = key.toString('base64');
callback(null, options);
});
}
So my ´User´ model became this:
// user.model.js (complete)
var mongoose = require('mongoose');
var schema = new mongoose.Schema({
name: String,
password: {
algorithm: String,
digest: String,
iterations: Number,
salt: String,
key: String
},
admin: Boolean
});
module.exports = mongoose.model('User', schema);
Have you noticed that the ´Hash´ function relies on the asynchronous´crypto.pbkdf2´ function? That’s just standard, so I wasn’t going to use the synchronous version on a second thought.
Then my problem was:
How do I make these filters work with deferred values?
Ta-da! Promises:
// user.controller.js (complete)
var Promise = require('es6-promise').Promise;
var fields = [
'name',
function (password) {
return new Promise(function (resolve, reject) {
var Hash = require(global.absPath + '/app/components/auth/hash');
Hash({plaintext: password}, function (error, result) {
if (error) {
reject(Error(error));
} else {
delete result.plaintext;
resolve(result);
}
});
});
},
function (admin) {
return !!admin.length;
}
];
var Item = require('./user.model');
var Controller = require(global.absPath + '/app/shared/CRUD.controller');
module.exports = Controller(Item, fields);
To make that work I had to change a bit the ´CRUD´ controller.
The first change was to separate the filtering from the assignment, so that I could later use the ´Promise.all´ method which allows to synchronize promises and values as well. That implied to pass from a ´CopyFields´ function which filters and assigns each value in turn to a ´FilterFields´ function which filters all values at once, thus making the assignments directly in the ´Create´ function.
// CRUD.controller.js (broken excerpt)
module.exports = CRUD_Controller;
function CRUD_Controller(Item, fields) {
//...
function Create(req, res) {
FilterFields(fields, req.body, function (fFields) {
var item = new Item();
fFields.forEach(function (fField) {
item[fField.name] = fField.value;
});
item.save(function(err) {
if (err) {
return res.send(err);
}
res.json({
message: 'Item created!'
});
});
});
}
function FilterFields(fields, data, callback) {
Promise
.all((fields || []).map(Filter))
.then(callback)
.catch(function (error) {
console.log(error);
});
function Filter(field) {
var result;
switch (typeof field) {
case 'string':
result = {
name: field,
value: data[field]
};
break;
case 'function':
var matches = String(field).match(/^functions*(s*(w+)s*)/);
if (!(matches && matches[1])) {
console.log('Expected a function with only one argument.');
return;
}
result = {
name: matches[1],
value: field(data[matches[1]])
};
break;
}
return result;
}
}
//...
}
The second change was to add a needed special treatment for my promises. You may have noticed that, in the ´case ‘function’:´ above, ´result.value´ can be a promise BUT that won’t make ´result´ a promise itself!! So the code above wouldn’t work yet, because it would complete ´Promise.all´ before getting the hashed password. Finally, I got this:
// CRUD.controller.js (working excerpt)
module.exports = CRUD_Controller;
function CRUD_Controller(Item, fields) {
//...
function Create(req, res) {
FilterFields(fields, req.body, function (fFields) {
var item = new Item();
fFields.forEach(function (fField) {
item[fField.name] = fField.value;
});
item.save(function(err) {
if (err) {
return res.send(err);
}
res.json({
message: 'Item created!'
});
});
});
}
function FilterFields(fields, data, callback) {
Promise
.all((fields || []).map(Filter))
.then(callback)
.catch(function (error) {
console.log(error);
});
function Filter(field) {
var result;
switch (typeof field) {
case 'string':
result = {
name: field,
value: data[field]
};
break;
case 'function':
var matches = String(field).match(/^functions*(s*(w+)s*)/);
if (!(matches && matches[1])) {
console.log('Expected a function with only one argument.');
return;
}
result = {
name: matches[1],
value: field(data[matches[1]])
};
if (stuff.isPromise(result.value)) {
var promise = new Promise(function (resolve, reject) {
var name = result.name;
result.value.then(function (value) {
resolve({
name: name,
value: value
});
}).catch(function (error) {
reject(Error(error));
});
});
result = promise;
}
break;
}
return result;
}
}
//...
}
The added lines make ´result´ a promise if ´result.value´ is one: ´result´ will eventually resolve to the expected result. BTW, the ´stuff.isPromise´ method is the classical ´object.then && typeof object.then == ‘function’´.