Initial commit

pull/4/head
Sven Slootweg 5 years ago
commit 7b44381a7d

2
.gitignore vendored

@ -0,0 +1,2 @@
node_modules
yarn.lock

@ -0,0 +1,193 @@
# validatem
A library for argument and object validation of any kind. Unlike many other validation libraries, it *doesn't* use a validation library, and it's designed to be modular, composable and extensible, rather than being stuck with just the built-in validators/combinators.
This is still a work-in-progress, so don't expect the API to be stable yet, and the documentation will be incomplete. Nevertheless, below are some rough instructions and notes on how to use it. Bug reports are very welcome, but don't expect them to be fixed quickly!
An example of the kind of output that this library produces:
```
ConfigurationError: One or more validation errors occurred:
- At session -> secret: Must be changed to a secure, randomly generated key
- At rpc: Encountered an unexpected property 'hostname'
- At rpc: Encountered an unexpected property 'port'
- At rpc: Encountered an unexpected property 'username'
- At rpc: Encountered an unexpected property 'password'
- At timeSlots -> 21:49-21:55 -> (value) -> transactionQueue -> valueThreshold: Must be a number
- At failedBatchReporter -> nodemailer: Encountered an unexpected property 'subject'
at <snip>
at Module._compile (module.js:653:30)
at Object.Module._extensions..js (module.js:664:10)
at Module.load (module.js:566:32)
at tryModuleLoad (module.js:506:12)
at Function.Module._load (module.js:498:3)
at Function.Module.runMain (module.js:694:10)
at startup (bootstrap_node.js:204:16)
at bootstrap_node.js:625:3
```
The examples shown below are extracted from a real-world project. They therefore contain a lot of business logic, and are larger and more complex than a simple library example would normally be.
## Example (argument validation)
```js
"use strict";
const Promise = require("bluebird");
const { validateArguments, required, when } = require("validatem");
module.exports = function ({ db, knex }) {
return {
create: function ({ transactionId, origin, operatorId, events }, tx) {
validateArguments(arguments, [
["data", {
transactionId: [ required ],
origin: [ required ],
operatorId: [],
events: [ required ],
}, when((data) => data.origin === "operator", {
operatorId: [ required ]
})],
["tx", required]
]);
return Promise.try(() => {
return tx("transaction_events")
.insert(events.map((event) => {
return {
transaction_id: transactionId,
operator_id: operatorId,
origin: origin,
type: event.type,
field: event.field,
value: JSON.stringify(event.value)
};
}))
.returning("*");
});
},
getForTransaction: function ({ transactionId }, tx) {
validateArguments(arguments, [
["data", { transactionId: [ required ] }],
["tx", required]
]);
return Promise.try(() => {
return tx("transaction_events")
.where({ transaction_id: transactionId })
.orderBy("created_at", "ASC");
});
}
};
};
```
## Example (object validation)
```js
"use strict";
const { validateValue, ValidationError, required, isString, isNumber, isBoolean, when, anyProperty, matchesFormat } = require("validatem");
let validateTransactionQueueSettings = [ {
valueThreshold: [ isNumber ],
timeThreshold: [ isNumber ],
transactionThreshold: [ isNumber ]
}, (thresholds) => {
if (thresholds.valueThreshold == null && thresholds.timeThreshold == null && thresholds.transactionThreshold == null) {
throw new Error("Must specify at least one value, time, or transaction count threshold");
}
} ];
let validateLimitsSettings = {
dailyLimit: [ isNumber ],
};
module.exports = function validateConfiguration (configuration) {
return validateValue(configuration, {
session: {
secret: [ required, isString, (value) => {
if (value === "THIS_IS_AN_INSECURE_VALUE_AND_YOU_SHOULD_REPLACE_IT") {
throw new ValidationError("Must be changed to a secure, randomly generated key");
}
} ]
},
database: {
hostname: [ required, isString ],
database: [ required, isString ],
username: [ required, isString ],
password: [ required, isString ],
},
rpc: {
bitcoind: {
hostname: [ required, isString ],
port: [ required, isNumber ],
username: [ required, isString ],
password: [ required, isString ],
}
},
transactionQueue: validateTransactionQueueSettings,
limits: validateLimitsSettings,
timeSlots: anyProperty({
key: [ required, isString ],
value: {
transactionQueue: validateTransactionQueueSettings
}
}),
machines: anyProperty({
key: [ required, isString, matchesFormat(/^[0-9]+$/) ],
value: {
comment: [ isString ],
limits: validateLimitsSettings
}
}),
failedBatchReporter: [ {
enabled: [ required, isBoolean ],
includeTransactions: [ isBoolean ],
nodemailer: {
from: [ required, isString ],
to: [ required, isString ],
subjectLines: {
failed: [ isString ],
cancelled: [ isString ],
held: [ isString ]
}
}
}, when((options) => options.enabled === true), {
includeTransactions: [ required ],
nodemailer: [ required ]
} ],
pairing: {
hostname: [ required, isString ],
port: [ required, isNumber ]
},
notifications: {
confirmed: [ required, isString ]
}
});
};
```
## Usage notes
Validation methods:
- Use `validateArguments` for validating a function call's arguments against a set of rules. The first argument is always the `arguments` variable, the second argument is an array of argument rules.
- Each argument rule is itself an array, where the first element is the name of the argument (for error formatting purposes), and all elements after that are rules to apply.
- Because this approach uses `arguments`, it will *only* work in regular functions, *not* in arrow functions. However, arrow functions don't generally need argument validation (as they're not usually used for exported functions), so this should not be an issue.
- When using this for functions that you export from a module, you should make sure that the argument name you specify matches that in the documentation of your module. This helps users understand exactly what they got wrong, from the generated error message.
- Use `validateValue` for validating an object (or any other kind of value) against a set of rules. The first argument is the object/value, the second argument are the rules.
Rules:
- In any place where a validation rule is expected, you can specify either a single rule or an array of them.
- A rule for an object-shaped value is expressed as an object literal. A rule for an array-shaped value is *not* expressed as an array literal, however; use the `arrayOf` method for that.
- For objects that are used as an arbitrary key/value mapping rather than having fixed keys, use the `anyProperty` method. It takes an object with two sets of validation rules: one for the `key`, and one for the `value`. See the example above.
- By default, any properties in an object that weren't declared in the validation rules, are rejected. Wrap the object in `allowExtraProperties(...)` to allow unspecified properties to pass validation.
How validators work:
- Every validator is simply a function that takes a single argument: the value to be validated. That means that your custom validators can just be expressed inline as single-argument functions, that either throw a `ValidationError` (exported by the module) or return an array of them (in case of multiple failures) if something is amiss.
- All thrown/returned ValidationErrors are compiled together into a single ValidationError, which has a) a .errors property with all the errors, and b) a multi-line `.message` with a human-readable description of all the errors that occurred, and at what paths.
For more details (especially how to write validators that handle complex datastructures like objects), you'll be stuck looking at the source code for now :)

@ -0,0 +1,264 @@
"use strict";
const createError = require("create-error");
const assureArray = require("assure-array");
const defaultValue = require("default-value");
let ValidationError = createError("ValidationError", { path: [] });
function validateValue(value, argRules) {
let argRules_ = assureArray(argRules);
let isRequired = argRules_.some((rule) => rule.___validationMarker === "required");
if (isRequired && value == null) {
return [ new ValidationError(`Required value is missing`) ];
} else if (value != null) {
let actualRules = argRules_.filter((rule) => {
return (rule.___validationMarker == null);
});
let errors = [];
for (let rule of actualRules) {
if (typeof rule === "object") {
errors = validateObject(value, rule);
} else {
try {
errors = defaultValue(rule(value), []);
} catch (error) {
if (error instanceof ValidationError) {
errors = [ error ];
}
}
}
if (errors.length > 0) {
break;
}
}
return errors;
}
}
function validateObject(object, rules, allowExtra = false) {
let errors = [];
if (!allowExtra) {
errors = Object.keys(object).map((propertyName) => {
if (rules[propertyName] == null) {
return new ValidationError(`Encountered an unexpected property '${propertyName}'`);
} else {
return null;
}
}).filter((error) => {
return (error != null);
});
}
if (errors.length > 0) {
return errors;
} else {
return Object.keys(rules).map((key) => {
let errors = validateValue(object[key], rules[key]);
return annotateErrors(key, object[key], assureArray(errors));
}).reduce((allErrors, errors) => {
return allErrors.concat(errors);
}, []);
}
}
function validateArgumentList(args, rules) {
if (args.length > rules.length) {
return [ new ValidationError(`Got ${args.length} arguments, but only expected ${rules.length}`) ];
} else {
return rules.map((item, i) => {
let arg = args[i];
let argName = item[0];
let argRules = item.slice(1);
if (typeof argName !== "string") {
throw new Error("First item in the argument rules list must be the argument name");
} else {
let errors = validateValue(arg, argRules);
return annotateErrors(argName, arg, assureArray(errors));
}
}).reduce((allErrors, errors) => {
return allErrors.concat(errors);
}, []);
}
}
function annotateErrors(pathSegment, value, errors) {
return errors.map((error) => {
error.path = assureArray(pathSegment).concat(error.path);
if (error.value == null) {
error.value = value;
}
return error;
});
}
function aggregrateErrors(errors) {
let rephrasedErrors = errors.map((error) => {
/* TODO: Make immutable */
let path = (error.path.length > 0)
? error.path.join(" -> ")
: "(root)";
error.message = `At ${path}: ${error.message}`;
return error;
});
let detailLines = rephrasedErrors.map((error) => {
return ` - ${error.message}`;
}).join("\n");
if (errors.length > 0) {
throw new ValidationError(`One or more validation errors occurred:\n${detailLines}`, {
errors: rephrasedErrors
});
}
}
module.exports = {
validateArguments: function (args, rules) {
let errors = validateArgumentList(args, rules);
aggregrateErrors(errors);
},
validateValue: function (value, rules) {
let errors = validateValue(value, assureArray(rules));
aggregrateErrors(errors);
},
ValidationError: ValidationError,
required: {___validationMarker: "required"},
isFunction: function (value) {
if (typeof value !== "function") {
throw new ValidationError("Must be a function");
}
},
isString: function (value) {
if (typeof value !== "string") {
throw new ValidationError("Must be a string");
}
},
isNumber: function (value) {
if (typeof value !== "number") {
throw new ValidationError("Must be a number");
}
},
isBoolean: function (value) {
if (typeof value !== "boolean") {
throw new ValidationError("Must be a boolean");
}
},
isDate: function (value) {
if (!(value instanceof Date)) {
throw new ValidationError("Must be a Date object");
}
},
either: function (...alternatives) {
if (alternatives.length === 0) {
throw new Error("Must specify at least one alternative");
} else {
return function (value) {
let errors = [];
for (let alternative of alternatives) {
let result = validateValue(value, alternative);
if (result.length === 0) {
return;
} else {
errors = errors.concat(result);
}
}
let errorList = errors.map((error) => {
return `"${error.message}"`;
}).join(", ");
return new ValidationError(`Must satisfy at least one of: ${errorList}`, { errors: errors });
};
}
},
when: function (predicate, rules) {
let rules_ = assureArray(rules).map((rule) => {
if (typeof rule === "object") {
/* We automatically allow extraneous properties in a `when` clause, because it'll generally be used as a partial addition to an otherwise-fully-specified object structure. */
return allowExtraProperties(rule);
} else {
return rule;
}
});
return function (value) {
let matches = predicate(value);
if (matches) {
return validateValue(value, rules_);
} else {
return [];
}
};
},
matchesFormat: function (regex) {
return function (value) {
if (!regex.test(value)) {
throw new ValidationError(`Must match format: ${regex}`);
}
};
},
arrayOf: function (rules) {
let rules_ = assureArray(rules);
return function (value) {
if (!Array.isArray(value)) {
throw new ValidationError("Must be an array");
} else {
return value.map((item, i) => {
let errors = validateValue(item, rules_);
return annotateErrors(i, item, assureArray(errors));
}).reduce((allErrors, errors) => {
return allErrors.concat(errors);
}, []);
}
};
},
anyProperty: function (rules) {
let keyRules = assureArray(defaultValue(rules.key, []));
let valueRules = assureArray(defaultValue(rules.value, []));
return function (object) {
if (typeof object !== "object" || Array.isArray(object)) {
throw new ValidationError("Must be an object");
} else {
return Object.keys(object).map((key) => {
let value = object[key];
let keyErrors = validateValue(key, keyRules);
let valueErrors = validateValue(value, valueRules);
let annotatedKeyErrors = annotateErrors([key, "(key)"], key, assureArray(keyErrors));
let annotatedValueErrors = annotateErrors([key, "(value)"], value, assureArray(valueErrors));
return annotatedKeyErrors.concat(annotatedValueErrors);
}).reduce((allErrors, errors) => {
return allErrors.concat(errors);
}, []);
}
};
},
allowExtraProperties: function (rules) {
return function (value) {
return validateObject(value, rules, true);
};
}
};

@ -0,0 +1,13 @@
{
"name": "validatem",
"version": "0.1.0",
"main": "index.js",
"repository": "http://git.cryto.net/joepie91/node-validatem.git",
"author": "Sven Slootweg <admin@cryto.net>",
"license": "WTFPL OR CC0-1.0",
"dependencies": {
"assure-array": "^1.0.0",
"create-error": "^0.3.1",
"default-value": "^1.0.0"
}
}
Loading…
Cancel
Save