UNPKG

jsii

Version:

[![Join the chat at https://cdk.Dev](https://img.shields.io/static/v1?label=Slack&message=cdk.dev&color=brightgreen&logo=slack)](https://cdk.dev) [![All Contributors](https://img.shields.io/github/all-contributors/aws/jsii/main?label=%E2%9C%A8%20All%20Con

308 lines • 11.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); 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