diff --git a/lib/entry-points.js b/lib/entry-points.js index ee64cb6a5e..083567dfb2 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -29647,13 +29647,13 @@ var require_helpers = __commonJS({ exports2.encodePath = function encodePointer(a) { return a.map(pathEncoder).join(""); }; - exports2.getDecimalPlaces = function getDecimalPlaces(number) { + exports2.getDecimalPlaces = function getDecimalPlaces(number2) { var decimalPlaces = 0; - if (isNaN(number)) return decimalPlaces; - if (typeof number !== "number") { - number = Number(number); + if (isNaN(number2)) return decimalPlaces; + if (typeof number2 !== "number") { + number2 = Number(number2); } - var parts = number.toString().split("e"); + var parts = number2.toString().split("e"); if (parts.length === 2) { if (parts[1][0] !== "-") { return decimalPlaces; @@ -78733,9 +78733,9 @@ var require_enum_object = __commonJS({ if (!isEnumObject(enumObject)) throw new Error("not a typescript enum object"); let values = []; - for (let [name, number] of Object.entries(enumObject)) - if (typeof number == "number") - values.push({ name, number }); + for (let [name, number2] of Object.entries(enumObject)) + if (typeof number2 == "number") + values.push({ name, number: number2 }); return values; } exports2.listEnumValues = listEnumValues; @@ -92751,10 +92751,10 @@ var require_util12 = __commonJS({ return arg == null; } exports2.isNullOrUndefined = isNullOrUndefined; - function isNumber(arg) { + function isNumber2(arg) { return typeof arg === "number"; } - exports2.isNumber = isNumber; + exports2.isNumber = isNumber2; function isString3(arg) { return typeof arg === "string"; } @@ -101524,24 +101524,24 @@ var require_operators = __commonJS({ } }.call(this); } - function toIntegerOrInfinity(number) { - number = Number2(number); - if (NumberIsNaN(number)) { + function toIntegerOrInfinity(number2) { + number2 = Number2(number2); + if (NumberIsNaN(number2)) { return 0; } - if (number < 0) { - throw new ERR_OUT_OF_RANGE("number", ">= 0", number); + if (number2 < 0) { + throw new ERR_OUT_OF_RANGE("number", ">= 0", number2); } - return number; + return number2; } - function drop(number, options = void 0) { + function drop(number2, options = void 0) { if (options != null) { validateObject(options, "options"); } if ((options === null || options === void 0 ? void 0 : options.signal) != null) { validateAbortSignal(options.signal, "options.signal"); } - number = toIntegerOrInfinity(number); + number2 = toIntegerOrInfinity(number2); return async function* drop2() { var _options$signal5; if (options !== null && options !== void 0 && (_options$signal5 = options.signal) !== null && _options$signal5 !== void 0 && _options$signal5.aborted) { @@ -101552,20 +101552,20 @@ var require_operators = __commonJS({ if (options !== null && options !== void 0 && (_options$signal6 = options.signal) !== null && _options$signal6 !== void 0 && _options$signal6.aborted) { throw new AbortError(); } - if (number-- <= 0) { + if (number2-- <= 0) { yield val; } } }.call(this); } - function take(number, options = void 0) { + function take(number2, options = void 0) { if (options != null) { validateObject(options, "options"); } if ((options === null || options === void 0 ? void 0 : options.signal) != null) { validateAbortSignal(options.signal, "options.signal"); } - number = toIntegerOrInfinity(number); + number2 = toIntegerOrInfinity(number2); return async function* take2() { var _options$signal7; if (options !== null && options !== void 0 && (_options$signal7 = options.signal) !== null && _options$signal7 !== void 0 && _options$signal7.aborted) { @@ -101576,10 +101576,10 @@ var require_operators = __commonJS({ if (options !== null && options !== void 0 && (_options$signal8 = options.signal) !== null && _options$signal8 !== void 0 && _options$signal8.aborted) { throw new AbortError(); } - if (number-- > 0) { + if (number2-- > 0) { yield val; } - if (number <= 0) { + if (number2 <= 0) { return; } } @@ -124893,8 +124893,8 @@ var require_util16 = __commonJS({ parts.push(format.substring(last)); return parts.join(""); }; - util3.formatNumber = function(number, decimals, dec_point, thousands_sep) { - var n = number, c = isNaN(decimals = Math.abs(decimals)) ? 2 : decimals; + util3.formatNumber = function(number2, decimals, dec_point, thousands_sep) { + var n = number2, c = isNaN(decimals = Math.abs(decimals)) ? 2 : decimals; var d = dec_point === void 0 ? "," : dec_point; var t = thousands_sep === void 0 ? "." : thousands_sep, s = n < 0 ? "-" : ""; var i = parseInt(n = Math.abs(+n || 0).toFixed(c), 10) + ""; @@ -144502,35 +144502,95 @@ function isArray(value) { function isString(value) { return typeof value === "string"; } +function isNumber(value) { + return typeof value === "number"; +} function isStringOrUndefined(value) { return value === void 0 || isString(value); } -var string = { - validate: isString, - required: true -}; +function defaultCheck(validate) { + return (arg) => ({ unknownKeys: [], invalidKeys: [], valid: validate(arg) }); +} +function makeValidator(validate, required = true) { + return { + validate, + check: defaultCheck(validate), + required + }; +} +var string = makeValidator(isString); +var number = makeValidator(isNumber); function optionalOrNull(validator) { return { validate: (val) => { return val === void 0 || val === null || validator.validate(val); }, + check: (val, opts, path29) => { + if (val === void 0 || val === null) { + return successfulCheckSchema(); + } + return validator.check(val, opts, path29); + }, required: false }; } function validateSchema(schema, obj) { + const result = checkSchema(schema, obj, { failFast: true }); + return result.valid; +} +function successfulCheckSchema() { + return { + valid: true, + unknownKeys: [], + invalidKeys: [] + }; +} +function checkSchema(schema, obj, options = {}, path29 = "") { + const result = successfulCheckSchema(); + const inputKeys = new Set(Object.keys(obj)); + const invalidKeys = /* @__PURE__ */ new Set(); for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; + inputKeys.delete(key); + invalidKeys.add(key); if (validator.required && !hasKey) { - return false; + result.valid = false; + if (options.failFast) { + break; + } + continue; } if (validator.required && (obj[key] === void 0 || obj[key] === null)) { - return false; + result.valid = false; + if (options.failFast) { + break; + } + continue; } - if (hasKey && !validator.validate(obj[key])) { - return false; + if (hasKey) { + const checkResult = validator.check(obj[key], options, `${path29}.${key}`); + result.unknownKeys.push(...checkResult.unknownKeys); + result.invalidKeys.push(...checkResult.invalidKeys); + if (checkResult.invalidKeys.length > 0) { + invalidKeys.delete(key); + } + if (!checkResult.valid) { + result.valid = false; + if (options.failFast) { + break; + } + continue; + } } + invalidKeys.delete(key); } - return true; + for (const remainingKey of inputKeys) { + result.unknownKeys.push(`${path29}.${remainingKey}`); + } + for (const invalidKey of invalidKeys) { + result.invalidKeys.push(`${path29}.${invalidKey}`); + } + return result; } // src/util.ts diff --git a/src/json/index.test.ts b/src/json/index.test.ts index eb259c757e..80edbedece 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -67,3 +67,76 @@ test("validateSchema - optional properties are optional", async (t) => { t.true(json.validateSchema(optionalSchema, { optionalKey: "" })); t.true(json.validateSchema(optionalSchema, { optionalKey: "foo" })); }); + +const arraySchema = { + arrayKey: json.array(json.number), +}; + +test("validateSchema - validates arrays", async (t) => { + // Arrays of numeric elements are accepted. + t.true(json.validateSchema(arraySchema, { arrayKey: [] })); + t.true(json.validateSchema(arraySchema, { arrayKey: [4] })); + t.true(json.validateSchema(arraySchema, { arrayKey: [4, 8] })); + t.true(json.validateSchema(arraySchema, { arrayKey: [4, 8, 15] })); + + // Other array elements are not accepted. + t.false(json.validateSchema(arraySchema, { arrayKey: [4, 8, 15, "bar"] })); + t.false(json.validateSchema(arraySchema, { arrayKey: [4, 8, undefined] })); + t.false(json.validateSchema(arraySchema, { arrayKey: [4, 8, 15, null] })); +}); + +const objectSchema = { + objectKey: json.object(arraySchema), +}; + +test("validateSchema - validates objects", async (t) => { + // Objects of the given schema are accepted. + t.true(json.validateSchema(objectSchema, { objectKey: { arrayKey: [] } })); + t.true(json.validateSchema(objectSchema, { objectKey: { arrayKey: [4] } })); + + // Other values are not accepted. + t.false(json.validateSchema(objectSchema, {})); + t.false(json.validateSchema(objectSchema, { objectKey: [] })); + t.false(json.validateSchema(objectSchema, { objectKey: undefined })); + t.false(json.validateSchema(objectSchema, { objectKey: null })); + t.false(json.validateSchema(objectSchema, { objectKey: "foo" })); + t.false(json.validateSchema(objectSchema, { objectKey: 123 })); +}); + +const checkSchemaTestSchema = { + rootKey: json.object(objectSchema), +}; + +test("checkSchema - reports unknown keys", async (t) => { + const result = json.checkSchema(checkSchemaTestSchema, { + rootKey: { + objectKey: { + arrayKey: [], + }, + nestedExtraKey: "foo", + }, + extraKey: "bar", + }); + + t.true(result.valid); + t.deepEqual( + result.unknownKeys.sort(), + [".extraKey", ".rootKey.nestedExtraKey"].sort(), + ); +}); + +test("checkSchema - reports invalid keys", async (t) => { + const result = json.checkSchema(checkSchemaTestSchema, { + rootKey: { + objectKey: { + arrayKey: ["foo"], + }, + }, + }); + + t.false(result.valid); + t.deepEqual( + result.invalidKeys.sort(), + [".rootKey.objectKey.arrayKey[0]"].sort(), + ); +}); diff --git a/src/json/index.ts b/src/json/index.ts index a55de5b589..78923f8bac 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -48,23 +48,107 @@ export function isStringOrUndefined( */ export type Validator = { validate: (val: unknown) => val is T; + check: ( + val: unknown, + opts: CheckSchemaOptions, + path: string, + ) => CheckSchemaResult; required: boolean; }; +function defaultCheck( + validate: (val: unknown) => val is any, +): (arg: unknown) => CheckSchemaResult { + return (arg) => ({ unknownKeys: [], invalidKeys: [], valid: validate(arg) }); +} + +function makeValidator( + validate: (arg: unknown) => arg is T, + required: boolean = true, +) { + return { + validate, + check: defaultCheck(validate), + required, + } as const satisfies Validator; +} + /** Extracts `T` from `Validator`. */ export type UnwrapValidator = V extends Validator ? A : never; /** A validator for string fields in schemas. */ -export const string = { - validate: isString, - required: true, -} as const satisfies Validator; +export const string = makeValidator(isString); /** A validator for number fields in schemas. */ -export const number = { - validate: isNumber, - required: true, -} as const satisfies Validator; +export const number = makeValidator(isNumber); + +/** A validator for arrays. */ +export function array(validator: Validator) { + const validate = (val: unknown) => { + return isArray(val) && val.every((e) => validator.validate(e)); + }; + return { + validate, + check: (val: unknown, opts: CheckSchemaOptions, path: string) => { + const result: CheckSchemaResult = successfulCheckSchema(); + + // The value must be an array. + if (!isArray(val)) { + result.valid = false; + return result; + } + + // Validate all elements of the array. + let index = 0; + for (const e of val) { + const elementPath = `${path}[${index}]`; + const eResult = validator.check(e, opts, `${elementPath}`); + + result.invalidKeys.push(...eResult.invalidKeys); + result.unknownKeys.push(...eResult.unknownKeys); + index++; + + if (!eResult.valid) { + result.valid = false; + + // Add the element path to `invalidKeys` if we didn't get + // any more specific ones from the element validator. + if (eResult.invalidKeys.length === 0) { + result.invalidKeys.push(elementPath); + } + + if (opts.failFast) { + return result; + } + + continue; + } + } + + return result; + }, + required: true, + } as const satisfies Validator; +} + +/** A validator for objects. */ +export function object< + S extends Schema, + T extends UnvalidatedObject = FromSchema, +>(schema: S) { + return { + validate: (val: unknown) => { + return isObject(val) && validateSchema(schema, val); + }, + check: (val, opts, path) => { + if (!isObject(val)) { + return invalidCheckSchema(); + } + return checkSchema(schema, val, opts, path); + }, + required: true, + } as const satisfies Validator; +} /** * Transforms a validator to be optional, accepting `undefined` or `null` for an @@ -75,6 +159,12 @@ export function optionalOrNull(validator: Validator) { validate: (val: unknown) => { return val === undefined || val === null || validator.validate(val); }, + check: (val, opts, path) => { + if (val === undefined || val === null) { + return successfulCheckSchema(); + } + return validator.check(val, opts, path); + }, required: false, } as const satisfies Validator; } @@ -88,6 +178,12 @@ export function optional(validator: Validator) { validate: (val: unknown): val is T | undefined => { return val === undefined || validator.validate(val); }, + check: (val, opts, path) => { + if (val === undefined) { + return successfulCheckSchema(); + } + return validator.check(val, opts, path); + }, required: false, } as const satisfies Validator; } @@ -117,28 +213,133 @@ export type FromSchema = { * @param obj The object to validate. * @returns Asserts that `obj` is of the `schema`'s type if validation is successful. */ -export function validateSchema( +export function validateSchema< + S extends Schema, + T extends UnvalidatedObject = FromSchema, +>(schema: S, obj: UnvalidatedObject): obj is T { + const result = checkSchema(schema, obj, { failFast: true }); + return result.valid; +} + +export interface CheckSchemaOptions { + /** Whether to stop validation after the first error. */ + failFast?: boolean; +} + +export interface CheckSchemaResult { + /** Whether the `obj` satisfies the schema. */ + valid: boolean; + /** Unknown keys that were found during validation. */ + unknownKeys: string[]; + /** Known keys that failed validation. */ + invalidKeys: string[]; +} + +/** + * Convenience function to produce a `CheckSchemaResult` where `valid: true`. + */ +function successfulCheckSchema(): CheckSchemaResult { + return { + valid: true, + unknownKeys: [], + invalidKeys: [], + }; +} + +/** + * Convenience function to produce a `CheckSchemaResult` where `valid: false`. + */ +function invalidCheckSchema(): CheckSchemaResult { + return { + valid: false, + unknownKeys: [], + invalidKeys: [], + }; +} + +export function checkSchema( schema: S, obj: UnvalidatedObject, -): obj is FromSchema { + options: CheckSchemaOptions = {}, + path: string = "", +): CheckSchemaResult { + const result: CheckSchemaResult = successfulCheckSchema(); + + // Track the set of input keys. We remove keys from this set as we recognise them + // during validation. + const inputKeys = new Set(Object.keys(obj)); + + // Track keys that have failed validation, starting with the empty set. + const invalidKeys = new Set(); + + // Loop through all keys in the object schema and validate that the given object + // satisfies the schema key. for (const [key, validator] of Object.entries(schema)) { const hasKey = key in obj; + // Remove key from set of unrecognised keys. + inputKeys.delete(key); + + // Add the key to the set of invalid keys. We remove it later once + // it passes validation. + invalidKeys.add(key); + // If the property is required, but absent, fail. if (validator.required && !hasKey) { - return false; + result.valid = false; + + if (options.failFast) { + break; + } + continue; } // If the property is required, but undefined or null, fail. if (validator.required && (obj[key] === undefined || obj[key] === null)) { - return false; + result.valid = false; + + if (options.failFast) { + break; + } + continue; } // If the property is present, validate it. - if (hasKey && !validator.validate(obj[key])) { - return false; + if (hasKey) { + const checkResult = validator.check(obj[key], options, `${path}.${key}`); + + result.unknownKeys.push(...checkResult.unknownKeys); + result.invalidKeys.push(...checkResult.invalidKeys); + + // If we have invalid keys from the validator, then that means that + // we have a more specific key than `key`. Remove `key` from the results. + if (checkResult.invalidKeys.length > 0) { + invalidKeys.delete(key); + } + + if (!checkResult.valid) { + result.valid = false; + + if (options.failFast) { + break; + } + continue; + } } + + // If we reach this point, the key has been successfully validated. + invalidKeys.delete(key); + } + + // If there are any remaining keys in `inputKeys`, add them to `unknownKeys`. + for (const remainingKey of inputKeys) { + result.unknownKeys.push(`${path}.${remainingKey}`); + } + + // If there are any remaining keys in `invalidKeys`, add them to the result. + for (const invalidKey of invalidKeys) { + result.invalidKeys.push(`${path}.${invalidKey}`); } - return true; + return result; }