jsii
Version:
[](https://cdk.dev) [;
exports.ObjectValidator = exports.ValidationError = exports.Match = exports.RuleSet = exports.RuleType = void 0;
var RuleType;
(function (RuleType) {
RuleType[RuleType["PASS"] = 0] = "PASS";
RuleType[RuleType["FAIL"] = 1] = "FAIL";
})(RuleType || (exports.RuleType = RuleType = {}));
class RuleSet {
get rules() {
return this._rules;
}
/**
* Return all fields for which a rule exists
*/
get fields() {
return [...new Set(this._rules.map((r) => r.field))];
}
/**
* Return a list of fields that are allowed, or undefined if all are allowed.
*/
get allowedFields() {
if (this.options.unexpectedFields === RuleType.FAIL) {
return this.fields;
}
return undefined;
}
/**
* Find all required fields by evaluating every rule in th set against undefined.
* If the rule fails, the key must be required.
*
* @returns A list of keys that must be included or undefined
*/
get requiredFields() {
const required = [];
for (const rule of this._rules) {
const key = rule.field;
const matcherResult = rule.matcher(undefined);
switch (rule.type) {
case RuleType.PASS:
if (!matcherResult) {
required.push(key);
}
break;
case RuleType.FAIL:
if (matcherResult) {
required.push(key);
}
break;
default:
continue;
}
}
return required;
}
constructor(options = {
unexpectedFields: RuleType.PASS,
}) {
this.options = options;
this._rules = [];
}
/**
* Requires the matcher to pass for the given field.
* Otherwise a violation is detected.
*
* @param field The field the rule applies to
* @param matcher The matcher function
*/
shouldPass(field, matcher) {
this._rules.push({ field, matcher, type: RuleType.PASS });
}
/**
* Detects a violation if the matcher is matching for a certain field.
*
* @param field The field the rule applies to
* @param matcher The matcher function
*/
shouldFail(field, matcher) {
this._rules.push({ field, matcher, type: RuleType.FAIL });
}
/**
* Imports all rules from an other rule set.
* Note that any options from the other rule set will be ignored.
*
* @param other The other rule set to import rules from.
*/
import(other) {
this._rules.push(...other.rules);
}
/**
* Records the field hints for the given rule set.
* Hints are values that are guaranteed to pass the rule.
* The list of hints is not guaranteed to be complete nor does it guarantee to return any values.
* This can be used to create synthetic values for testing for error messages.
*
* @returns A record of fields and allowed values
*/
getFieldHints() {
const fieldHints = {};
for (const rule of this._rules) {
// We are only interested in PASS rules here.
// For FAILs we still don't know which values would pass.
if (rule.type === RuleType.PASS) {
// run the matcher to record hints
rule.matcher(undefined, {
hints: (receivedHints) => {
var _a;
// if we have recorded hints, add them to the map
if (receivedHints) {
fieldHints[_a = rule.field] ?? (fieldHints[_a] = []);
fieldHints[rule.field].push(...receivedHints);
}
},
});
}
}
return fieldHints;
}
}
exports.RuleSet = RuleSet;
/**
* Helper to wrap a matcher with error reporting and hints
*/
function wrapMatcher(matcher, message, allowed) {
return (value, options) => {
options?.reporter?.(message(value));
if (allowed) {
options?.hints?.(allowed);
}
return matcher(value);
};
}
/**
* Helper to implement loose equality that is safe for any value
* Needed because there are some values that are equal as object, but not with ==
* There are also values that cannot be compared using == and will throw
*/
function looseEqual(a, b) {
try {
// if one of the values is an object (or array), but the other isn't - never consider them the same
if ([typeof a, typeof b].filter((t) => t === 'object').length === 1) {
return false;
}
// if both values are the same object
// or if both values are loose equal
return Object.is(a, b) || a == b;
}
catch {
return false;
}
}
class Match {
/**
* Value is optional, but if present should match
*/
static optional(matcher) {
return (value, options) => {
if (value == null) {
return true;
}
return matcher(value, options);
};
}
/**
* Value must be one of the allowed options
*/
static oneOf(...allowed) {
return wrapMatcher((actual) => allowed.includes(actual), (actual) => `Expected value to be one of ${JSON.stringify(allowed)}, got: ${JSON.stringify(actual)}`, allowed);
}
/**
* Value must be loosely equal to the expected value
* Arrays are compared by elements
*/
static eq(expected) {
return (actual, options) => {
if (Array.isArray(expected)) {
return Match.arrEq(expected)(actual, options);
}
options?.hints?.([expected]);
options?.reporter?.(`Expected value ${JSON.stringify(expected)}, got: ${JSON.stringify(actual)}`);
return looseEqual(actual, expected);
};
}
/**
* Value must be loosely equal to the expected value
* Arrays are compared by elements
*/
static arrEq(expected) {
return wrapMatcher((actual) => {
// if both are arrays and of the same length, compare elements
// if one of them is not, or they are a different length,
// skip comparing elements as the the equality check later will fail
if (Array.isArray(expected) && Array.isArray(actual) && expected.length == actual.length) {
// compare all elements with loose typing
return expected.every((e) => actual.some((a) => looseEqual(a, e)));
}
// all other values and arrays of different shape
return looseEqual(actual, expected);
}, (actual) => `Expected array matching ${JSON.stringify(expected)}, got: ${JSON.stringify(actual)}`, [expected]);
}
/**
* Compare strings, allows setting cases sensitivity
*/
static strEq(expected, caseSensitive = false) {
return wrapMatcher((actual) => {
// case insensitive
if (!caseSensitive && typeof actual === 'string') {
return looseEqual(expected.toLowerCase(), actual.toLowerCase());
}
// case sensitive
return looseEqual(actual, expected);
}, (actual) => `Expected string ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, [expected]);
}
}
exports.Match = Match;
/**
* Allows any value
*/
Match.ANY = (_val, _options) => true;
// eslint-disable-next-line @typescript-eslint/member-ordering
Match.TRUE = Match.eq(true);
// eslint-disable-next-line @typescript-eslint/member-ordering
Match.FALSE = Match.eq(false);
/**
* Missing (undefined) value
*/
// eslint-disable-next-line @typescript-eslint/member-ordering
Match.MISSING = wrapMatcher((actual) => actual === null || actual === undefined, (actual) => `Expected value to be present, got ${JSON.stringify(actual)}`, [undefined, null]);
class ValidationError extends Error {
constructor(violations) {
// error message is a list of violations
super('Data is invalid:\n' + violations.map((v) => v.field + ': ' + v.message).join('\n'));
this.violations = violations;
// Set the prototype explicitly.
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
exports.ValidationError = ValidationError;
class ObjectValidator {
constructor(ruleSet, dataName = 'data') {
this.ruleSet = ruleSet;
this.dataName = dataName;
}
/**
* Validated the provided data against the set of rules.
*
* @throws when the data is invalid
*
* @param data the data to be validated
*/
validate(data) {
// make sure data is an object
if (!(typeof data === 'object' && !Array.isArray(data) && data !== null)) {
throw new ValidationError([
{ field: this.dataName, message: 'Provided data must be an object, got: ' + JSON.stringify(data) },
]);
}
const checkedFields = new Set();
const violations = [];
// first check all defined rules
for (const rule of this.ruleSet.rules) {
const value = data[rule.field];
// Use a fallback message, but allow the matcher to report a better arrow
let violationMessage = 'Value is not allowed, got: ' + JSON.stringify(value);
const matchResult = rule.matcher(value, {
reporter: (message) => {
violationMessage = message;
},
});
switch (rule.type) {
case RuleType.PASS:
if (!matchResult) {
violations.push({
field: rule.field,
message: violationMessage,
});
}
break;
case RuleType.FAIL:
if (matchResult) {
violations.push({
field: rule.field,
message: violationMessage,
});
}
break;
default:
continue;
}
checkedFields.add(rule.field);
}
// finally check fields without any rules if they should fail the validation
if (this.ruleSet.options.unexpectedFields === RuleType.FAIL) {
const receivedFields = Object.keys(data);
for (const field of receivedFields) {
if (!checkedFields.has(field)) {
violations.push({ field: field, message: `Unexpected field, got: ${field}` });
}
}
}
// if we have encountered a violation, throw an error
if (violations.length > 0) {
throw new ValidationError(violations);
}
}
}
exports.ObjectValidator = ObjectValidator;
//# sourceMappingURL=validator.js.map
;