Browse Source

Rewrite to modular design

master
Sven Slootweg 1 year ago
parent
commit
aa644fa3f5
  1. 3
      .eslintrc
  2. 1
      .gitignore
  3. 242
      README.md
  4. 44
      example.js
  5. 310
      index.js
  6. 29
      package.json
  7. 41
      src/aggregrate-errors.js
  8. 5
      src/aggregrate-validation-error.js
  9. 58
      src/api/validate-arguments.js
  10. 14
      src/api/validate-options.js
  11. 6
      src/api/validate-value.js
  12. 17
      src/api/validation-method.js
  13. 28
      src/apply-validators.js
  14. 89
      src/compose-rules.js
  15. 10
      src/is-special/combinator.js
  16. 10
      src/is-special/required.js
  17. 10
      src/is-special/validation-result.js
  18. 1224
      yarn.lock

3
.eslintrc

@ -0,0 +1,3 @@
{
"extends": "@joepie91/eslint-config"
}

1
.gitignore

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

242
README.md

@ -1,194 +1,90 @@
# validatem
# @validatem/core
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.
The last validation library you'll ever need.
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!
* Does __every kind of validation__, and does it well: it doesn't matter whether you're validating function arguments, form data, JSON request bodies, configuration files, or whatever else. As long as it's structured data of some sort, Validatem can deal with it.
* Supports the notion of __virtual properties__ in validation errors, which means that even if your data *isn't* already structured data (eg. an encoded string of some sort), you can bring your own parser, and have it integrate cleanly.
* __Easy to read__; both the code that *uses* Validatem, and the validation error messages that it produces! Your validation code doubles as in-code format documentation, and users get clear feedback about what's wrong.
* Fully __composable__: it's trivial to use third-party validators, or to write your own (reusable!) validators, whether fully custom or made up of a few other validators chained together.
* Supports __value transformation__, which means that you can even encode things like "this value defaults to X" or "when this value is a number, it will be wrapped like so" in your validation code; this can save you a bunch of boilerplate, and makes your validation code *even more complete* as format documentation.
* Validatem has a __small and modular core__, and combined with its composability, this means you won't pull any more code into your project than is strictly necessary to make your validators work! This is also an important part of making Validatem suitable for use in libraries, eg. for argument validation.
* Many __off-the-shelf validators__ are already available! You can find the full list [here](https://validatem.cryto.net/modules).
* Extensively __documented__, with clear documentation on what is considered valid, and what is not. Likewise, the plumbing libraries that you can use to write your own validators and combinators, are also well-documented.
An example of the kind of output that this library produces:
While Validatem is suitable for any sort of validation, this unique combination of features and design choices makes it *especially* useful for validating arguments in the public API of libraries, unlike other validation libraries!
```
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)
For example, you might write something like the following (from the [`icssify` library](https://git.cryto.net/joepie91/icssify/src/master/index.js)):
```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");
});
}
};
module.exports = function (browserify, options) {
validateArguments(arguments, [
[ "browserify", required ],
[ "options", allowExtraProperties({
mode: oneOf([ "local", "global" ]),
before: arrayOf([ required, isPostcssPlugin ]),
after: arrayOf([ required, isPostcssPlugin ]),
extensions: arrayOf([ required, isString ])
})]
]);
// Implementation code goes here ...
};
```
## Example (object validation)
And calling it like so:
```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 ],
};
icssify(undefined, {
mode: "nonExistentMode",
before: [ NaN ],
unspecifiedButAllowedOption: true
})
```
... would then produce an error like this:
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 ]
}
});
};
```
ValidationError: One or more validation errors occurred:
- At browserify: Required value is missing
- At options -> mode: Must be one of: 'local', 'global'
- At options -> before -> 0: Must be a PostCSS plugin
```
## Still under construction!
I'm currently preparing Validatem for its 1.0.0 release. Until then, documentation will be missing in many places (though an example.js is usually included in each module!), the website probably won't work yet, and some bits of the API might still change.
In principle, Validatem is reliable enough already to use in real-world code - I use it in various of my own libraries and other projects. But be aware that you may need to change your code when 1.0.0 hits!
## Getting stuck?
__If you've read the documentation and you're still not quite sure how to solve your problem, please ask your question on the issue tracker!__
Usage questions are often documentation or usability bug reports in disguise. By asking your question on the issue tracker, you help us improve the documentation - so that the next person with your question doesn't need to ask!
## Why not just use Typescript?
Because it's strict in all the wrong places. It will yell at your perfectly valid abstraction just because you haven't figured out the magic incantation to make the compiler *believe* that it is valid, and at the same time it's incapable of doing business logic validation such as "is this a valid URL?" - *by design*.
Real-world validation is about more than just whether something is a string or a number. Validatem can deal with this; Typescript cannot.
## Why are there so many packages?
## Usage notes
Dependencies often introduce a lot of unnecessary complexity into a project. To avoid that problem, I've designed Validatem to consist of a lot of small, separately-usable pieces - even much of the core plumbing has been split up that way, specifically the bits that may be used by validator and combinator functions.
Validation methods:
This may sound counterintuitive; doesn't more dependencies mean *more* complexity? But in practice, "a dependency" in and of itself doesn't have a complexity cost at all; it's the code that is *in* the dependency where the complexity lies. The bigger a dependency is, the more complexity there is in that dependency, and the bigger the chance that some part of that complexity isn't even being used in your project!
- 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.
By packaging things as granularly as possible, you end up only importing code into your project *that you are actually using*. Any bit of logic that's never used, is somewhere in a package that is never even installed. As an example: using 10 modules with 1 function each, will add less complexity to your project than using 1 module with 100 functions.
Rules:
This has a lot of benefits, for both you and me:
- 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.
- __Easier to audit/review:__ When only the code you're actually using is added to your project, there will be less code for you to review. And because each piece is designed to be [loosely coupled](https://gist.github.com/joepie91/7f03a733a3a72d2396d6#coupled) and extensively documented, you can review each (tiny) piece in isolation; without having to trawl through mountains of source code to figure out how it's being called and what assumptions are being made there.
- __Easier to version and maintain:__ Most of the modules for Validatem will be completely done and feature-complete the moment they are written, never needing any updates at all. When occasionally a module *does* need an update, it will almost certainly not be one that breaks the API, because the API for each module is so simple that there isn't much *to* break.
- __Easier to upgrade:__ Because of almost nothing ever breaking the API, it also means that you'll rarely need to manually upgrade anything, if ever! The vast majority of module updates can be automatically applied, even many years into the future, *even* if a new (breaking) version of `validatem/@core` is ever released down the line.
- __Easier to fork:__ If for any reason you want to fork any part of Validatem, you can do so easily - *without* also having to maintain a big pile of validators, combinators, internals, and so on. You only need to fork and maintain the bit where you want to deviate from the official releases.
How validators work:
Of course, there being so many packages means it can be more difficult to find the specific package you want. That is why [the Validatem website has an extensive, categorized list](https://validatem.cryto.net/modules) of all the validators, combinators and utilities available for Validatem. Both officially-maintained ones, and third-party modules!
- 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.
- The default complex-datastructure validators (eg. object validation, `arrayOf`) will only check each value up until the first validator that fails, in the order they are defined. So, for example, each property of an object will be validated even if some of them fail; but once a given property has failed a validator, all the *other* validators for *that specific property* will not be run.
---
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 :)
Further documentation will be added later!

44
example.js

@ -0,0 +1,44 @@
"use strict";
const { validateValue } = require("./");
const isString = require("@validatem/is-string");
const isBoolean = require("@validatem/is-boolean");
const isNumber = require("@validatem/is-number");
const required = require("@validatem/required");
const arrayOf = require("@validatem/array-of");
const anyProperty = require("@validatem/any-property");
let data = {
one: "foo",
two: "bar",
array: [
"A",
false,
"C"
],
mapping: {
a: true,
b: false,
c: false,
d: NaN
}
};
validateValue(data, {
one: [ required, isString ],
two: [ required, isNumber ],
three: [ required, isBoolean ],
array: [ required, arrayOf([ required, isString ]) ],
mapping: [ required, anyProperty({
key: [ required ],
value: [ required, isBoolean ]
}) ]
});
/*
AggregrateValidationError: One or more validation errors occurred:
- At two: Must be a number
- At three: Required value is missing
- At array -> 1: Must be a string
- At mapping -> d -> (value): Must be a boolean
*/

310
index.js

@ -1,310 +1,12 @@
"use strict";
const createError = require("create-error");
const assureArray = require("assure-array");
const defaultValue = require("default-value");
const isPlainObj = require("is-plain-obj");
const util = require("util");
let ValidationError = createError("ValidationError", { path: [] });
function allowExtraProperties (rules) {
return function (value) {
return validateObject(value, rules, true);
};
}
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") {
/* FIXME: Check that this isn't an array, Date, Buffer, ... */
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
});
}
}
function validateArguments(args, rules) {
let errors = validateArgumentList(args, rules);
aggregrateErrors(errors);
}
function isPlainObject(value) {
if (!isPlainObj(value)) {
throw new ValidationError("Must be a plain object (eg. object literal)");
}
}
// Forcibly import this module before anything else, to make our cyclical dependency hack work (details are in ./src/compose-rules.js)
require("./src/compose-rules");
module.exports = {
validateArguments: validateArguments,
validateValue: function (value, rules) {
let errors = validateValue(value, assureArray(rules));
aggregrateErrors(errors);
},
validateOptions: function (args, optionsRules) {
return validateArguments(args, [
["options", isPlainObject].concat(assureArray(optionsRules))
]);
},
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");
}
},
isBuffer: function (value) {
if (!(value instanceof Buffer)) {
throw new ValidationError("Must be a Buffer object");
}
},
isPlainObject: isPlainObject,
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) {
if (rules == null) {
throw new Error("No rules specified for a `when` validation clause; did you misplace a parenthese?");
} else {
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}`);
}
};
},
oneOf: function (validValues) {
if (Array.isArray(validValues)) {
let validValueSet = new Set(validValues);
return function (value) {
if (!validValueSet.has(value)) {
throw new ValidationError(`Must be one of: ${validValues.map((item) => util.inspect(item)).join(", ")}`);
}
}
} else {
throw new Error("Argument to `oneOf` must be an array of values");
}
},
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));
validateArguments: require("./src/api/validate-arguments"),
validateOptions: require("./src/api/validate-options"),
validateValue: require("./src/api/validate-value"),
return annotatedKeyErrors.concat(annotatedValueErrors);
}).reduce((allErrors, errors) => {
return allErrors.concat(errors);
}, []);
}
};
},
allowExtraProperties: allowExtraProperties,
forbidden: function (value) {
if (value != null) {
throw new ValidationError("Value exists in a place that should be empty");
}
}
AggregrateValidationError: require("./src/aggregrate-validation-error")
};

29
package.json

@ -1,14 +1,35 @@
{
"name": "validatem",
"version": "0.2.1",
"name": "@validatem/core",
"description": "The last validation library you'll ever need.",
"keywords": ["validation", "input", "verification", "data"],
"version": "0.3.0",
"main": "index.js",
"repository": "http://git.cryto.net/joepie91/node-validatem.git",
"repository": "http://git.cryto.net/validatem/core.git",
"author": "Sven Slootweg <admin@cryto.net>",
"license": "WTFPL OR CC0-1.0",
"dependencies": {
"@validatem/annotate-errors": "^0.1.2",
"@validatem/any-property": "^0.1.0",
"@validatem/error": "^1.0.0",
"@validatem/is-plain-object": "^0.1.0",
"@validatem/match-validation-error": "^0.1.0",
"@validatem/match-versioned-special": "^0.1.0",
"@validatem/match-virtual-property": "^0.1.0",
"@validatem/normalize-rules": "^0.1.0",
"@validatem/required": "^0.1.0",
"@validatem/validation-result": "^0.1.1",
"array.prototype.flat": "^1.2.3",
"assure-array": "^1.0.0",
"create-error": "^0.3.1",
"default-value": "^1.0.0",
"is-plain-obj": "^2.0.0"
"is-arguments": "^1.0.4"
},
"devDependencies": {
"@joepie91/eslint-config": "^1.1.0",
"@validatem/array-of": "^0.1.0",
"@validatem/is-boolean": "^0.1.0",
"@validatem/is-number": "^0.1.1",
"@validatem/is-string": "^0.1.0",
"eslint": "^6.8.0"
}
}

41
src/aggregrate-errors.js

@ -0,0 +1,41 @@
"use strict";
const matchVirtualProperty = require("@validatem/match-virtual-property");
const AggregrateValidationError = require("./aggregrate-validation-error");
module.exports = function aggregrateAndThrowErrors(errors) {
let rephrasedErrors = errors.map((error) => {
let stringifiedPathSegments = error.path.map((segment) => {
if (segment == null) {
throw new Error(`Unexpected empty path segment encountered; this is a bug, please report it!`);
} else if (typeof segment === "string") {
return segment;
} else if (typeof segment === "number") {
return String(segment);
} else if (matchVirtualProperty(segment)) {
return `(${segment.name})`;
} else {
throw new Error(`Unexpected path segment encountered: ${segment}; this is a bug, please report it!`);
}
});
/* TODO: Make immutable */
let path = (stringifiedPathSegments.length > 0)
? stringifiedPathSegments.join(" -> ")
: "(root)";
error.message = `At ${path}: ${error.message}`;
return error;
});
let detailLines = rephrasedErrors.map((error) => {
return ` - ${error.message}`;
}).join("\n");
if (errors.length > 0) {
return new AggregrateValidationError(`One or more validation errors occurred:\n${detailLines}`, {
errors: rephrasedErrors
});
}
};

5
src/aggregrate-validation-error.js

@ -0,0 +1,5 @@
"use strict";
const createError = require("create-error");
module.exports = createError("AggregrateValidationError", { errors: [] });

58
src/api/validate-arguments.js

@ -0,0 +1,58 @@
"use strict";
const isArguments = require("is-arguments");
const flat = require("array.prototype.flat");
const ValidationError = require("@validatem/error");
const annotateErrors = require("@validatem/annotate-errors");
const applyValidators = require("../apply-validators");
const createValidationMethod = require("./validation-method");
// TODO: Simplify below by treating it like an array or object? Or would that introduce too much complexity through specialcasing?
// TODO: Find a way to produce validatem-style errors for the below invocation errors, without introducing recursion
module.exports = createValidationMethod((args, argumentDefinitions) => {
if (!isArguments(args)) {
throw new Error(`First argument is not an 'arguments' object; maybe you forgot to put it before the validation rules?`);
} else if (argumentDefinitions == null) {
throw new Error(`Validation rules (second argument) are missing; maybe you forgot to specify them?`);
} else if (args.length > argumentDefinitions.length) {
return {
errors: [ new ValidationError(`Got ${args.length} arguments, but only expected ${argumentDefinitions.length}`) ],
// Cast the below to an array, for consistency with *success* output, in case we ever want to expose the new values to the user in an error case too.
newValue: Array.from(args)
};
} else {
let results = argumentDefinitions.map((definition, i) => {
let argument = args[i];
let [ argumentName, ...argumentRules ] = definition;
if (typeof argumentName !== "string") {
throw new Error("First item in the argument rules list must be the argument name");
} else {
let validatorResult = applyValidators(argument, argumentRules);
return {
errors: annotateErrors({
pathSegments: [ argumentName ],
errors: validatorResult.errors
}),
newValue: (validatorResult.newValue !== undefined)
? validatorResult.newValue
: argument
};
}
});
let combinedErrors = results.map((result) => result.errors);
let flattenedErrors = flat(combinedErrors);
let newValues = results.map((result) => result.newValue);
return {
errors: flattenedErrors,
newValue: newValues
};
}
});

14
src/api/validate-options.js

@ -0,0 +1,14 @@
"use strict";
const assureArray = require("assure-array");
const isPlainObject = require("@validatem/is-plain-object");
const validateArguments = require("./validate-arguments");
module.exports = function (args, optionsRules) {
let result = validateArguments(args, [
["options", isPlainObject].concat(assureArray(optionsRules))
]);
return result[0];
};

6
src/api/validate-value.js

@ -0,0 +1,6 @@
"use strict";
const applyValidators = require("../apply-validators");
const createValidationMethod = require("./validation-method");
module.exports = createValidationMethod(applyValidators);

17
src/api/validation-method.js

@ -0,0 +1,17 @@
"use strict";
const aggregrateErrors = require("../aggregrate-errors");
module.exports = function createValidationMethod(validationFunction) {
return function (... args) {
let { errors, newValue } = validationFunction(... args);
let aggregratedError = aggregrateErrors(errors);
if (aggregratedError != null) {
throw aggregratedError;
} else {
return newValue;
}
};
};

28
src/apply-validators.js

@ -0,0 +1,28 @@
"use strict";
const defaultValue = require("default-value");
const normalizeRules = require("@validatem/normalize-rules");
const matchValidationError = require("@validatem/match-validation-error");
const validationResult = require("@validatem/validation-result");
const composeRules = require("./compose-rules");
module.exports = function applyValidators(value, rules, context = {}) {
let normalizedRules = normalizeRules(rules, { normalizeObjects: true });
let validator = composeRules.compose(normalizedRules);
let { errors, newValue } = validator(value, context);
// NOTE: We mutate the error here, because Error objects are not safely cloneable
for (let error of errors) {
if (matchValidationError(error) && error.value == null) {
error.value = value;
}
}
return validationResult({
errors: errors,
newValue: defaultValue(newValue, value)
});
};

89
src/compose-rules.js

@ -0,0 +1,89 @@
"use strict";
const ValidationError = require("@validatem/error");
const matchValidationError = require("@validatem/match-validation-error");
const validationResult = require("@validatem/validation-result");
const isRequiredMarker = require("./is-special/required");
const isValidationResult = require("./is-special/validation-result");
const isCombinator = require("./is-special/combinator");
const applyValidators = require("./apply-validators");
// NOTE: If a validator returns a transformed value, the *next* validator in line will receive this *transformed* value instead of the original value. This allows composing/chaining different transformations, and keeping that model consistent with how providing an array of validators would work. If this behaviour is not desirable, the user can wrap `ignoreResult` around the offending validator to retain the previous (potentially original input) value.
// NOTE: Assigning to a property *on* module.exports as a cyclic dependency handling workaround for compose-rules -> apply-validators -> compose-rules -> ...
// This works because apply-validators gets a reference the default `module.exports` object when it requires compose-rules (this module) and it can complete initialization, *after* which we make our compose-rules implementation available as a property on said (already-referenced) object.
module.exports.compose = function composeValidators(validators) {
let isRequired = validators.some((rule) => isRequiredMarker(rule));
let nonMarkerRules = validators.filter((rule) => !isRequiredMarker(rule));
return function composedValidator(value, context) {
if (isRequired && value == null) {
return validationResult({
errors: [ new ValidationError(`Required value is missing`) ],
newValue: value
});
} else if (value != null) {
let lastValue = value;
let errors = [];
for (let rule of nonMarkerRules) {
try {
let result = isCombinator(rule)
? rule.callback(lastValue, applyValidators, context)
: rule(lastValue, context);
if (result !== undefined) {
let transformedValue;
if (isValidationResult(result)) {
if (Array.isArray(result.errors)) {
let nonValidationErrors = result.errors.filter((error) => !matchValidationError(error));
if (nonValidationErrors.length === 0) {
errors = result.errors;
transformedValue = result.newValue;
} else {
// We should never reach this point, but it could possibly occur if someone erroneously includes non-ValidationError errors in a validationResult. Note that this is a last-ditch handler, and so we only throw the first non-ValidationError error and let the user sort out the rest, if any.
throw result.errors[0];
}
} else {
throw new Error(`The 'errors' in a validationResult must be an array`);
}
} else if (result instanceof Error) {
// We could interpret returned Errors as either values or a throw substitute. Let's wait for users to file issues, so that we know what people *actually* need here.
throw new Error(`It is currently not allowed to return an Error object from a validator. If you have a reason to need this, please file a bug!`);
} else {
transformedValue = result;
}
if (transformedValue != null) {
lastValue = transformedValue;
}
}
} catch (error) {
if (matchValidationError(error)) {
errors = [ error ];
} else {
throw error;
}
}
if (errors.length > 0) {
break;
}
}
return validationResult({
errors: errors,
// NOTE: The below conditional is to make a composed series of validator mirror a normal validator, in the sense that it only returns a `newValue` if something has actually changed. For transparent composability, we want to be as close to the behaviour of a non-composed validator as possible.
newValue: (lastValue !== value)
? lastValue
: undefined
});
} else {
return validationResult({ newValue: undefined });
}
};
};

10
src/is-special/combinator.js

@ -0,0 +1,10 @@
"use strict";
const createVersionedSpecialCheck = require("@validatem/match-versioned-special");
module.exports = createVersionedSpecialCheck({
markerProperty: "___validatem_isCombinator",
versionProperty: "___validatem_combinatorVersion",
friendlyName: "a combinator",
expectedVersions: [ 1 ]
});

10
src/is-special/required.js

@ -0,0 +1,10 @@
"use strict";
const createVersionedSpecialCheck = require("@validatem/match-versioned-special");
module.exports = createVersionedSpecialCheck({
markerProperty: "___validatem_isRequiredMarker",
versionProperty: "___validatem_requiredMarkerVersion",
friendlyName: "a required-field marker",
expectedVersions: [ 1 ]
});

10
src/is-special/validation-result.js

@ -0,0 +1,10 @@
"use strict";
const createVersionedSpecialCheck = require("@validatem/match-versioned-special");
module.exports = createVersionedSpecialCheck({
markerProperty: "___validatem_isValidationResult",
versionProperty: "___validatem_validationResultVersion",
friendlyName: "a validation result object",
expectedVersions: [ 1 ]
});

1224
yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save