From 3f2eea81f0844f12924448caa9dbfdf1d575041e Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Fri, 17 Apr 2026 11:27:35 +0200 Subject: [PATCH 1/4] node: implement validator for simple types --- lib/internal/bootstrap/realm.js | 3 +- lib/internal/errors.js | 5 + lib/internal/process/pre_execution.js | 10 + lib/internal/validator/compile.js | 293 ++++++++++++++++++++++++++ lib/internal/validator/defaults.js | 62 ++++++ lib/internal/validator/errors.js | 26 +++ lib/internal/validator/schema.js | 108 ++++++++++ lib/internal/validator/validate.js | 258 +++++++++++++++++++++++ lib/validator.js | 15 ++ src/node_options.cc | 5 + src/node_options.h | 1 + 11 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 lib/internal/validator/compile.js create mode 100644 lib/internal/validator/defaults.js create mode 100644 lib/internal/validator/errors.js create mode 100644 lib/internal/validator/schema.js create mode 100644 lib/internal/validator/validate.js create mode 100644 lib/validator.js diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 0415763e360246..70c76c61aec384 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -130,9 +130,10 @@ const schemelessBlockList = new SafeSet([ 'quic', 'test', 'test/reporters', + 'validator', ]); // Modules that will only be enabled at run time. -const experimentalModuleList = new SafeSet(['ffi', 'sqlite', 'quic', 'stream/iter', 'zlib/iter']); +const experimentalModuleList = new SafeSet(['ffi', 'sqlite', 'quic', 'stream/iter', 'validator', 'zlib/iter']); // Set up process.binding() and process._linkedBinding(). { diff --git a/lib/internal/errors.js b/lib/internal/errors.js index c40eed86bca834..95f18179dcac14 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1900,6 +1900,11 @@ E('ERR_UNSUPPORTED_RESOLVE_REQUEST', E('ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX', '%s', SyntaxError); E('ERR_USE_AFTER_CLOSE', '%s was closed', Error); +E('ERR_VALIDATOR_INVALID_SCHEMA', + (path, reason) => { + if (path) return `Invalid schema at "${path}": ${reason}`; + return `Invalid schema: ${reason}`; + }, TypeError, HideStackFramesError); // This should probably be a `TypeError`. E('ERR_VALID_PERFORMANCE_ENTRY_TYPE', 'At least one valid performance entry type is required', Error); diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 16a80c2d4f410f..0676589d7ed3b5 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -117,6 +117,7 @@ function prepareExecution(options) { setupFFI(); setupSQLite(); setupStreamIter(); + setupValidator(); setupQuic(); setupWebStorage(); setupWebsocket(); @@ -412,6 +413,15 @@ function setupStreamIter() { BuiltinModule.allowRequireByUsers('zlib/iter'); } +function setupValidator() { + if (getOptionValue('--no-experimental-validator')) { + return; + } + + const { BuiltinModule } = require('internal/bootstrap/realm'); + BuiltinModule.allowRequireByUsers('validator'); +} + function setupQuic() { if (!getOptionValue('--experimental-quic')) { return; diff --git a/lib/internal/validator/compile.js b/lib/internal/validator/compile.js new file mode 100644 index 00000000000000..e80af17c760e16 --- /dev/null +++ b/lib/internal/validator/compile.js @@ -0,0 +1,293 @@ +'use strict'; + +const { + ArrayIsArray, + ArrayPrototypeIncludes, + ArrayPrototypeJoin, + ArrayPrototypePush, + ArrayPrototypeSlice, + NumberIsFinite, + NumberIsInteger, + ObjectFreeze, + ObjectKeys, + RegExp, + SafeSet, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_VALIDATOR_INVALID_SCHEMA, + }, +} = require('internal/errors'); + +const kValidTypes = new SafeSet([ + 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null', +]); + +const kStringConstraints = new SafeSet([ + 'type', 'minLength', 'maxLength', 'pattern', 'enum', 'default', +]); + +const kNumberConstraints = new SafeSet([ + 'type', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', + 'multipleOf', 'default', +]); + +const kArrayConstraints = new SafeSet([ + 'type', 'items', 'minItems', 'maxItems', 'default', +]); + +const kObjectConstraints = new SafeSet([ + 'type', 'properties', 'required', 'additionalProperties', 'default', +]); + +const kSimpleConstraints = new SafeSet([ + 'type', 'default', +]); + +const kConstraintsByType = { + __proto__: null, + string: kStringConstraints, + number: kNumberConstraints, + integer: kNumberConstraints, + boolean: kSimpleConstraints, + null: kSimpleConstraints, + array: kArrayConstraints, + object: kObjectConstraints, +}; + +function formatPath(parentPath, key) { + if (parentPath === '') return key; + return `${parentPath}.${key}`; +} + +function validateNonNegativeInteger(value, path, name) { + if (!NumberIsInteger(value) || value < 0) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, `"${name}" must be a non-negative integer`); + } +} + +function validateFiniteNumber(value, path, name) { + if (typeof value !== 'number' || !NumberIsFinite(value)) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, `"${name}" must be a finite number`); + } +} + +function checkUnknownConstraints(definition, allowedSet, path) { + const keys = ObjectKeys(definition); + for (let i = 0; i < keys.length; i++) { + if (!allowedSet.has(keys[i])) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, + `unknown constraint "${keys[i]}" for type "${definition.type}"`); + } + } +} + +function compileSchemaNode(definition, path) { + if (typeof definition !== 'object' || definition === null || + ArrayIsArray(definition)) { + throw new ERR_INVALID_ARG_TYPE( + path || 'definition', 'a plain object', definition); + } + + const type = definition.type; + if (typeof type !== 'string' || !kValidTypes.has(type)) { + const validTypes = ArrayPrototypeJoin( + ['string', 'number', 'integer', 'boolean', 'object', 'array', 'null'], + ', '); + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, `"type" must be one of: ${validTypes}`); + } + + const allowedConstraints = kConstraintsByType[type]; + checkUnknownConstraints(definition, allowedConstraints, path); + + const compiled = { __proto__: null, type }; + + if ('default' in definition) { + compiled.default = definition.default; + compiled.hasDefault = true; + } else { + compiled.hasDefault = false; + } + + switch (type) { + case 'string': + compileStringConstraints(definition, compiled, path); + break; + case 'number': + case 'integer': + compileNumberConstraints(definition, compiled, path); + break; + case 'array': + compileArrayConstraints(definition, compiled, path); + break; + case 'object': + compileObjectConstraints(definition, compiled, path); + break; + default: + break; + } + + return ObjectFreeze(compiled); +} + +function compileStringConstraints(definition, compiled, path) { + if ('minLength' in definition) { + validateNonNegativeInteger(definition.minLength, path, 'minLength'); + compiled.minLength = definition.minLength; + } + if ('maxLength' in definition) { + validateNonNegativeInteger(definition.maxLength, path, 'maxLength'); + compiled.maxLength = definition.maxLength; + } + if (compiled.minLength !== undefined && compiled.maxLength !== undefined && + compiled.minLength > compiled.maxLength) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"minLength" must not be greater than "maxLength"'); + } + if ('pattern' in definition) { + if (typeof definition.pattern !== 'string') { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"pattern" must be a string'); + } + try { + compiled.pattern = new RegExp(definition.pattern); + } catch { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, `"pattern" is not a valid regular expression: ${definition.pattern}`); + } + compiled.patternSource = definition.pattern; + } + if ('enum' in definition) { + if (!ArrayIsArray(definition.enum) || definition.enum.length === 0) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"enum" must be a non-empty array'); + } + compiled.enum = ArrayPrototypeSlice(definition.enum); + } +} + +function compileNumberConstraints(definition, compiled, path) { + if ('minimum' in definition) { + validateFiniteNumber(definition.minimum, path, 'minimum'); + compiled.minimum = definition.minimum; + } + if ('maximum' in definition) { + validateFiniteNumber(definition.maximum, path, 'maximum'); + compiled.maximum = definition.maximum; + } + if ('exclusiveMinimum' in definition) { + validateFiniteNumber(definition.exclusiveMinimum, path, 'exclusiveMinimum'); + compiled.exclusiveMinimum = definition.exclusiveMinimum; + } + if ('exclusiveMaximum' in definition) { + validateFiniteNumber(definition.exclusiveMaximum, path, 'exclusiveMaximum'); + compiled.exclusiveMaximum = definition.exclusiveMaximum; + } + if ('multipleOf' in definition) { + validateFiniteNumber(definition.multipleOf, path, 'multipleOf'); + if (definition.multipleOf <= 0) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"multipleOf" must be greater than 0'); + } + compiled.multipleOf = definition.multipleOf; + } +} + +function compileArrayConstraints(definition, compiled, path) { + if ('minItems' in definition) { + validateNonNegativeInteger(definition.minItems, path, 'minItems'); + compiled.minItems = definition.minItems; + } + if ('maxItems' in definition) { + validateNonNegativeInteger(definition.maxItems, path, 'maxItems'); + compiled.maxItems = definition.maxItems; + } + if (compiled.minItems !== undefined && compiled.maxItems !== undefined && + compiled.minItems > compiled.maxItems) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"minItems" must not be greater than "maxItems"'); + } + if ('items' in definition) { + compiled.items = compileSchemaNode(definition.items, formatPath(path, 'items')); + } +} + +function compileObjectConstraints(definition, compiled, path) { + if ('properties' in definition) { + if (typeof definition.properties !== 'object' || + definition.properties === null || + ArrayIsArray(definition.properties)) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"properties" must be a plain object'); + } + const propKeys = ObjectKeys(definition.properties); + const compiledProps = { __proto__: null }; + const propNames = []; + for (let i = 0; i < propKeys.length; i++) { + const key = propKeys[i]; + ArrayPrototypePush(propNames, key); + compiledProps[key] = compileSchemaNode( + definition.properties[key], + formatPath(path, `properties.${key}`)); + } + compiled.properties = ObjectFreeze(compiledProps); + compiled.propertyNames = propNames; + } + + if ('required' in definition) { + if (!ArrayIsArray(definition.required)) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"required" must be an array of strings'); + } + for (let i = 0; i < definition.required.length; i++) { + if (typeof definition.required[i] !== 'string') { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"required" must be an array of strings'); + } + if (compiled.properties && + !ArrayPrototypeIncludes(compiled.propertyNames, definition.required[i])) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, + `required property "${definition.required[i]}" is not defined in "properties"`); + } + } + compiled.required = ArrayPrototypeSlice(definition.required); + } + + if ('additionalProperties' in definition) { + if (typeof definition.additionalProperties !== 'boolean') { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + path, '"additionalProperties" must be a boolean'); + } + compiled.additionalProperties = definition.additionalProperties; + } else { + compiled.additionalProperties = true; + } +} + +/** + * Validate and lower a user schema definition into a frozen, null-prototype + * representation consumed by `validateValue()` and `applyDefaults()`. + * + * All schema errors (unknown type, bad constraints, malformed `required`, + * non-compiling `pattern`, etc.) surface from this call as + * `ERR_VALIDATOR_INVALID_SCHEMA` so they fail fast at `new Schema(...)` + * rather than on each `validate(data)`. + * @param {object} definition User schema definition. + * @returns {object} Frozen compiled schema node. + */ +function compileSchema(definition) { + if (typeof definition !== 'object' || definition === null || + ArrayIsArray(definition)) { + throw new ERR_INVALID_ARG_TYPE('definition', 'a plain object', definition); + } + return compileSchemaNode(definition, ''); +} + +module.exports = { compileSchema }; diff --git a/lib/internal/validator/defaults.js b/lib/internal/validator/defaults.js new file mode 100644 index 00000000000000..339789d61181b6 --- /dev/null +++ b/lib/internal/validator/defaults.js @@ -0,0 +1,62 @@ +'use strict'; + +const { + ArrayIsArray, + ArrayPrototypePush, + ObjectKeys, + ObjectPrototypeHasOwnProperty, +} = primordials; + +/** + * Return a new value with schema defaults applied to missing or `undefined` + * object properties, recursing into nested object and array schemas. + * + * Input is not mutated. Only property slots whose schema declares a + * `default` get filled; missing intermediate objects are not created, so + * `applyDefaults({})` against `{ a: { b: { default: 1 } } }` returns `{}`, + * not `{ a: { b: 1 } }`. Non-object / non-array inputs are returned + * unchanged. + * @param {any} data Value to augment with defaults. + * @param {object} compiled Compiled schema node produced by `compileSchema()`. + * @returns {any} New value with defaults applied (never mutates `data`). + */ +function applyDefaultsInternal(data, compiled) { + if (compiled.type !== 'object' || typeof data !== 'object' || + data === null || ArrayIsArray(data)) { + if (compiled.type === 'array' && ArrayIsArray(data) && + compiled.items !== undefined) { + const result = []; + for (let i = 0; i < data.length; i++) { + ArrayPrototypePush(result, applyDefaultsInternal(data[i], compiled.items)); + } + return result; + } + return data; + } + + const result = { __proto__: null }; + const dataKeys = ObjectKeys(data); + for (let i = 0; i < dataKeys.length; i++) { + const key = dataKeys[i]; + result[key] = data[key]; + } + + if (compiled.properties !== undefined) { + for (let i = 0; i < compiled.propertyNames.length; i++) { + const key = compiled.propertyNames[i]; + const propSchema = compiled.properties[key]; + if (!ObjectPrototypeHasOwnProperty(result, key) || + result[key] === undefined) { + if (propSchema.hasDefault) { + result[key] = propSchema.default; + } + } else if (propSchema.type === 'object' || propSchema.type === 'array') { + result[key] = applyDefaultsInternal(result[key], propSchema); + } + } + } + + return result; +} + +module.exports = { applyDefaults: applyDefaultsInternal }; diff --git a/lib/internal/validator/errors.js b/lib/internal/validator/errors.js new file mode 100644 index 00000000000000..66b909bde2f055 --- /dev/null +++ b/lib/internal/validator/errors.js @@ -0,0 +1,26 @@ +'use strict'; + +const { + ObjectFreeze, +} = primordials; + +const codes = ObjectFreeze({ + __proto__: null, + INVALID_TYPE: 'INVALID_TYPE', + MISSING_REQUIRED: 'MISSING_REQUIRED', + STRING_TOO_SHORT: 'STRING_TOO_SHORT', + STRING_TOO_LONG: 'STRING_TOO_LONG', + PATTERN_MISMATCH: 'PATTERN_MISMATCH', + ENUM_MISMATCH: 'ENUM_MISMATCH', + NUMBER_TOO_SMALL: 'NUMBER_TOO_SMALL', + NUMBER_TOO_LARGE: 'NUMBER_TOO_LARGE', + NUMBER_NOT_MULTIPLE: 'NUMBER_NOT_MULTIPLE', + NOT_INTEGER: 'NOT_INTEGER', + ARRAY_TOO_SHORT: 'ARRAY_TOO_SHORT', + ARRAY_TOO_LONG: 'ARRAY_TOO_LONG', + ADDITIONAL_PROPERTY: 'ADDITIONAL_PROPERTY', +}); + +module.exports = { + codes, +}; diff --git a/lib/internal/validator/schema.js b/lib/internal/validator/schema.js new file mode 100644 index 00000000000000..ce460fdee36d33 --- /dev/null +++ b/lib/internal/validator/schema.js @@ -0,0 +1,108 @@ +'use strict'; + +const { + ArrayIsArray, + ObjectFreeze, + ObjectKeys, + SafeWeakSet, + Symbol, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_VALIDATOR_INVALID_SCHEMA, + }, +} = require('internal/errors'); + +const { inspect } = require('internal/util/inspect'); + +const { compileSchema } = require('internal/validator/compile'); +const { validateValue } = require('internal/validator/validate'); +const { applyDefaults } = require('internal/validator/defaults'); + +const kCompiledSchema = Symbol('kCompiledSchema'); +const kDefinition = Symbol('kDefinition'); + +function deepFreezeDefinition(value, seen) { + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) { + throw new ERR_VALIDATOR_INVALID_SCHEMA( + '', 'schema definition contains a circular reference'); + } + seen.add(value); + + let out; + if (ArrayIsArray(value)) { + out = []; + for (let i = 0; i < value.length; i++) { + out[i] = deepFreezeDefinition(value[i], seen); + } + } else { + out = {}; + const keys = ObjectKeys(value); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + // `default` is typed {any}; keep the user's reference verbatim so + // Dates, Maps, class instances, etc. survive toJSON() round-trips. + out[key] = key === 'default' ? + value[key] : deepFreezeDefinition(value[key], seen); + } + } + + seen.delete(value); + return ObjectFreeze(out); +} + +class Schema { + constructor(definition) { + if (typeof definition !== 'object' || definition === null || + ArrayIsArray(definition)) { + throw new ERR_INVALID_ARG_TYPE('definition', 'a plain object', definition); + } + + // Snapshot first: this deep-freezes the user-supplied definition and + // rejects circular references with ERR_VALIDATOR_INVALID_SCHEMA instead + // of letting `compileSchema` recurse until it blows the stack. + this[kDefinition] = deepFreezeDefinition(definition, new SafeWeakSet()); + this[kCompiledSchema] = compileSchema(definition); + } + + validate(data) { + const errors = []; + validateValue(data, this[kCompiledSchema], '', errors); + return ObjectFreeze({ + __proto__: null, + valid: errors.length === 0, + errors: ObjectFreeze(errors), + }); + } + + applyDefaults(data) { + return applyDefaults(data, this[kCompiledSchema]); + } + + toJSON() { + return this[kDefinition]; + } + + get [SymbolToStringTag]() { + return 'Schema'; + } + + [inspect.custom](depth, options) { + if (depth < 0) return 'Schema [Object]'; + return `Schema ${inspect(this[kDefinition], { + ...options, + depth: options.depth == null ? null : options.depth - 1, + })}`; + } + + static validate(definition, data) { + const schema = new Schema(definition); + return schema.validate(data); + } +} + +module.exports = { Schema }; diff --git a/lib/internal/validator/validate.js b/lib/internal/validator/validate.js new file mode 100644 index 00000000000000..42cb9979e198b2 --- /dev/null +++ b/lib/internal/validator/validate.js @@ -0,0 +1,258 @@ +'use strict'; + +const { + ArrayIsArray, + ArrayPrototypePush, + MathAbs, + NumberEPSILON, + NumberIsFinite, + NumberIsInteger, + NumberIsNaN, + ObjectKeys, + ObjectPrototypeHasOwnProperty, + RegExpPrototypeExec, + String, +} = primordials; + +const { codes } = require('internal/validator/errors'); + +function addError(errors, path, message, code) { + ArrayPrototypePush(errors, { + __proto__: null, + path, + message, + code, + }); +} + +function formatPath(parentPath, key) { + if (parentPath === '') return String(key); + return `${parentPath}.${key}`; +} + +function formatArrayPath(parentPath, index) { + return `${parentPath}[${index}]`; +} + +function getTypeName(value) { + if (value === null) return 'null'; + if (ArrayIsArray(value)) return 'array'; + return typeof value; +} + +/** + * Validate a value against a compiled schema node and push any failures to + * `errors` as `{ path, message, code }` objects. + * + * Validation never throws; all failures accumulate in `errors`. Recursion + * does not track visited nodes: cyclic input will stack-overflow (documented + * limitation; callers supplying untrusted data should guard upstream). + * @param {any} value Runtime value being validated. + * @param {object} compiled Compiled schema node produced by `compileSchema()`. + * @param {string} path Path to `value` from the validation root. + * @param {Array} errors Mutated in place with any error records. + */ +function validateValue(value, compiled, path, errors) { + const expected = compiled.type; + const actual = getTypeName(value); + + switch (expected) { + case 'string': + if (actual !== 'string') { + addError(errors, path, + `Expected type string, got ${actual}`, + codes.INVALID_TYPE); + return; + } + validateStringConstraints(value, compiled, path, errors); + break; + case 'number': + if (actual !== 'number' || NumberIsNaN(value)) { + addError(errors, path, + `Expected type number, got ${NumberIsNaN(value) ? 'NaN' : actual}`, + codes.INVALID_TYPE); + return; + } + validateNumberConstraints(value, compiled, path, errors); + break; + case 'integer': + if (actual !== 'number' || NumberIsNaN(value)) { + addError(errors, path, + `Expected type integer, got ${NumberIsNaN(value) ? 'NaN' : actual}`, + codes.INVALID_TYPE); + return; + } + if (!NumberIsInteger(value)) { + addError(errors, path, + `Expected an integer, got ${value}`, + codes.NOT_INTEGER); + return; + } + validateNumberConstraints(value, compiled, path, errors); + break; + case 'boolean': + if (actual !== 'boolean') { + addError(errors, path, + `Expected type boolean, got ${actual}`, + codes.INVALID_TYPE); + } + break; + case 'null': + if (value !== null) { + addError(errors, path, + `Expected null, got ${actual}`, + codes.INVALID_TYPE); + } + break; + case 'array': + if (!ArrayIsArray(value)) { + addError(errors, path, + `Expected type array, got ${actual}`, + codes.INVALID_TYPE); + return; + } + validateArrayConstraints(value, compiled, path, errors); + break; + case 'object': + if (actual !== 'object' || value === null || ArrayIsArray(value)) { + addError(errors, path, + `Expected type object, got ${actual}`, + codes.INVALID_TYPE); + return; + } + validateObjectConstraints(value, compiled, path, errors); + break; + } +} + +function validateStringConstraints(value, compiled, path, errors) { + if (compiled.minLength !== undefined && value.length < compiled.minLength) { + addError(errors, path, + `String length ${value.length} is less than minimum ${compiled.minLength}`, + codes.STRING_TOO_SHORT); + } + if (compiled.maxLength !== undefined && value.length > compiled.maxLength) { + addError(errors, path, + `String length ${value.length} exceeds maximum ${compiled.maxLength}`, + codes.STRING_TOO_LONG); + } + if (compiled.pattern !== undefined) { + if (RegExpPrototypeExec(compiled.pattern, value) === null) { + addError(errors, path, + `String does not match pattern "${compiled.patternSource}"`, + codes.PATTERN_MISMATCH); + } + } + if (compiled.enum !== undefined) { + let found = false; + for (let i = 0; i < compiled.enum.length; i++) { + if (value === compiled.enum[i]) { + found = true; + break; + } + } + if (!found) { + addError(errors, path, + `Value "${value}" is not one of the allowed values`, + codes.ENUM_MISMATCH); + } + } +} + +function validateNumberConstraints(value, compiled, path, errors) { + if (!NumberIsFinite(value)) { + addError(errors, path, + `Expected a finite number, got ${value}`, + codes.INVALID_TYPE); + return; + } + if (compiled.minimum !== undefined && value < compiled.minimum) { + addError(errors, path, + `Value ${value} is less than minimum ${compiled.minimum}`, + codes.NUMBER_TOO_SMALL); + } + if (compiled.exclusiveMinimum !== undefined && + value <= compiled.exclusiveMinimum) { + addError(errors, path, + `Value ${value} is less than or equal to exclusive minimum ${compiled.exclusiveMinimum}`, + codes.NUMBER_TOO_SMALL); + } + if (compiled.maximum !== undefined && value > compiled.maximum) { + addError(errors, path, + `Value ${value} exceeds maximum ${compiled.maximum}`, + codes.NUMBER_TOO_LARGE); + } + if (compiled.exclusiveMaximum !== undefined && + value >= compiled.exclusiveMaximum) { + addError(errors, path, + `Value ${value} is greater than or equal to exclusive maximum ${compiled.exclusiveMaximum}`, + codes.NUMBER_TOO_LARGE); + } + if (compiled.multipleOf !== undefined) { + const remainder = MathAbs(value % compiled.multipleOf); + const threshold = NumberEPSILON * + MathAbs(value > compiled.multipleOf ? value : compiled.multipleOf); + if (remainder > threshold && remainder < compiled.multipleOf - threshold) { + addError(errors, path, + `Value ${value} is not a multiple of ${compiled.multipleOf}`, + codes.NUMBER_NOT_MULTIPLE); + } + } +} + +function validateArrayConstraints(value, compiled, path, errors) { + if (compiled.minItems !== undefined && value.length < compiled.minItems) { + addError(errors, path, + `Array length ${value.length} is less than minimum ${compiled.minItems}`, + codes.ARRAY_TOO_SHORT); + } + if (compiled.maxItems !== undefined && value.length > compiled.maxItems) { + addError(errors, path, + `Array length ${value.length} exceeds maximum ${compiled.maxItems}`, + codes.ARRAY_TOO_LONG); + } + if (compiled.items !== undefined) { + for (let i = 0; i < value.length; i++) { + validateValue(value[i], compiled.items, formatArrayPath(path, i), errors); + } + } +} + +function validateObjectConstraints(value, compiled, path, errors) { + if (compiled.required !== undefined) { + for (let i = 0; i < compiled.required.length; i++) { + const key = compiled.required[i]; + if (!ObjectPrototypeHasOwnProperty(value, key)) { + addError(errors, formatPath(path, key), + `Property "${key}" is required`, + codes.MISSING_REQUIRED); + } + } + } + + if (compiled.properties !== undefined) { + for (let i = 0; i < compiled.propertyNames.length; i++) { + const key = compiled.propertyNames[i]; + if (ObjectPrototypeHasOwnProperty(value, key)) { + validateValue( + value[key], compiled.properties[key], + formatPath(path, key), errors); + } + } + } + + if (!compiled.additionalProperties) { + const valueKeys = ObjectKeys(value); + for (let i = 0; i < valueKeys.length; i++) { + const key = valueKeys[i]; + if (compiled.properties === undefined || + !ObjectPrototypeHasOwnProperty(compiled.properties, key)) { + addError(errors, formatPath(path, key), + `Additional property "${key}" is not allowed`, + codes.ADDITIONAL_PROPERTY); + } + } + } +} + +module.exports = { validateValue }; diff --git a/lib/validator.js b/lib/validator.js new file mode 100644 index 00000000000000..c081e12d914c33 --- /dev/null +++ b/lib/validator.js @@ -0,0 +1,15 @@ +'use strict'; + +const { + emitExperimentalWarning, +} = require('internal/util'); + +emitExperimentalWarning('validator'); + +const { Schema } = require('internal/validator/schema'); +const { codes } = require('internal/validator/errors'); + +module.exports = { + Schema, + codes, +}; diff --git a/src/node_options.cc b/src/node_options.cc index bbb72d2ba1bcf4..f3709fbbeee1e9 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -610,6 +610,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "experimental iterable streams API (node:stream/iter)", &EnvironmentOptions::experimental_stream_iter, kAllowedInEnvvar); + AddOption("--experimental-validator", + "experimental node:validator module", + &EnvironmentOptions::experimental_validator, + kAllowedInEnvvar, + true); AddOption("--experimental-quic", #ifndef OPENSSL_NO_QUIC "experimental QUIC support", diff --git a/src/node_options.h b/src/node_options.h index e910cb011431ab..515486d8bc106b 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -132,6 +132,7 @@ class EnvironmentOptions : public Options { bool experimental_websocket = true; bool experimental_sqlite = HAVE_SQLITE; bool experimental_stream_iter = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_validator = true; bool webstorage = HAVE_SQLITE; bool experimental_quic = EXPERIMENTALS_DEFAULT_VALUE; std::string localstorage_file; From 09f28385182095199fcff260f2422172e037eb9d Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Fri, 17 Apr 2026 11:28:23 +0200 Subject: [PATCH 2/4] test(validator): implements tests for main validator types --- test/parallel/test-validator-array.js | 92 +++++++++ test/parallel/test-validator-deep-nesting.js | 173 ++++++++++++++++ test/parallel/test-validator-defaults.js | 114 +++++++++++ test/parallel/test-validator-disabled.js | 29 +++ test/parallel/test-validator-errors.js | 132 ++++++++++++ test/parallel/test-validator-esm.mjs | 25 +++ .../test-validator-experimental-warning.js | 26 +++ test/parallel/test-validator-nested.js | 128 ++++++++++++ test/parallel/test-validator-number.js | 120 +++++++++++ test/parallel/test-validator-object.js | 156 ++++++++++++++ .../test-validator-pattern-runtime.js | 99 +++++++++ test/parallel/test-validator-schema-basic.js | 170 ++++++++++++++++ .../test-validator-schema-validation.js | 190 ++++++++++++++++++ test/parallel/test-validator-static.js | 73 +++++++ test/parallel/test-validator-string.js | 103 ++++++++++ test/parallel/test-validator-types.js | 126 ++++++++++++ test/parallel/test-validator-unicode.js | 84 ++++++++ 17 files changed, 1840 insertions(+) create mode 100644 test/parallel/test-validator-array.js create mode 100644 test/parallel/test-validator-deep-nesting.js create mode 100644 test/parallel/test-validator-defaults.js create mode 100644 test/parallel/test-validator-disabled.js create mode 100644 test/parallel/test-validator-errors.js create mode 100644 test/parallel/test-validator-esm.mjs create mode 100644 test/parallel/test-validator-experimental-warning.js create mode 100644 test/parallel/test-validator-nested.js create mode 100644 test/parallel/test-validator-number.js create mode 100644 test/parallel/test-validator-object.js create mode 100644 test/parallel/test-validator-pattern-runtime.js create mode 100644 test/parallel/test-validator-schema-basic.js create mode 100644 test/parallel/test-validator-schema-validation.js create mode 100644 test/parallel/test-validator-static.js create mode 100644 test/parallel/test-validator-string.js create mode 100644 test/parallel/test-validator-types.js create mode 100644 test/parallel/test-validator-unicode.js diff --git a/test/parallel/test-validator-array.js b/test/parallel/test-validator-array.js new file mode 100644 index 00000000000000..d682e515513afd --- /dev/null +++ b/test/parallel/test-validator-array.js @@ -0,0 +1,92 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('minItems - passes at boundary', () => { + const schema = new Schema({ type: 'array', minItems: 2 }); + assert.strictEqual(schema.validate([1, 2]).valid, true); +}); + +test('minItems - fails below', () => { + const schema = new Schema({ type: 'array', minItems: 2 }); + const result = schema.validate([1]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'ARRAY_TOO_SHORT'); +}); + +test('maxItems - passes at boundary', () => { + const schema = new Schema({ type: 'array', maxItems: 3 }); + assert.strictEqual(schema.validate([1, 2, 3]).valid, true); +}); + +test('maxItems - fails above', () => { + const schema = new Schema({ type: 'array', maxItems: 3 }); + const result = schema.validate([1, 2, 3, 4]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'ARRAY_TOO_LONG'); +}); + +test('items - validates each item', () => { + const schema = new Schema({ + type: 'array', + items: { type: 'string' }, + }); + assert.strictEqual(schema.validate(['a', 'b', 'c']).valid, true); +}); + +test('items - reports errors with index path', () => { + const schema = new Schema({ + type: 'array', + items: { type: 'string' }, + }); + const result = schema.validate(['a', 42, 'c']); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.errors[0].path, '[1]'); + assert.strictEqual(result.errors[0].code, 'INVALID_TYPE'); +}); + +test('items - multiple errors', () => { + const schema = new Schema({ + type: 'array', + items: { type: 'number', minimum: 0 }, + }); + const result = schema.validate([1, -1, 'x', 3]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 2); +}); + +test('minItems === maxItems pins exact length', () => { + const schema = new Schema({ type: 'array', minItems: 2, maxItems: 2 }); + assert.strictEqual(schema.validate([1, 2]).valid, true); + + const tooShort = schema.validate([1]); + assert.strictEqual(tooShort.valid, false); + assert.strictEqual(tooShort.errors[0].code, 'ARRAY_TOO_SHORT'); + + const tooLong = schema.validate([1, 2, 3]); + assert.strictEqual(tooLong.valid, false); + assert.strictEqual(tooLong.errors[0].code, 'ARRAY_TOO_LONG'); +}); + +test('empty array is valid', () => { + const schema = new Schema({ type: 'array', items: { type: 'string' } }); + assert.strictEqual(schema.validate([]).valid, true); +}); + +test('nested array schemas', () => { + const schema = new Schema({ + type: 'array', + items: { + type: 'array', + items: { type: 'number' }, + }, + }); + assert.strictEqual(schema.validate([[1, 2], [3, 4]]).valid, true); + const result = schema.validate([[1, 'x']]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, '[0][1]'); +}); diff --git a/test/parallel/test-validator-deep-nesting.js b/test/parallel/test-validator-deep-nesting.js new file mode 100644 index 00000000000000..67f25fa4158ed0 --- /dev/null +++ b/test/parallel/test-validator-deep-nesting.js @@ -0,0 +1,173 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('five-level nested object path', () => { + const schema = new Schema({ + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { + type: 'object', + properties: { + c: { + type: 'object', + properties: { + d: { + type: 'object', + properties: { + e: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const result = schema.validate({ a: { b: { c: { d: { e: 42 } } } } }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.errors[0].path, 'a.b.c.d.e'); + assert.strictEqual(result.errors[0].code, 'INVALID_TYPE'); +}); + +test('deeply nested arrays of arrays', () => { + const schema = new Schema({ + type: 'array', + items: { + type: 'array', + items: { + type: 'array', + items: { type: 'number' }, + }, + }, + }); + + const result = schema.validate([[[1, 2], [3, 'bad']]]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, '[0][1][1]'); + assert.strictEqual(result.errors[0].code, 'INVALID_TYPE'); +}); + +test('mixed arrays and objects deep path', () => { + const schema = new Schema({ + type: 'object', + properties: { + teams: { + type: 'array', + items: { + type: 'object', + properties: { + members: { + type: 'array', + items: { + type: 'object', + properties: { + addresses: { + type: 'array', + items: { + type: 'object', + properties: { + zip: { type: 'string', minLength: 5 }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const result = schema.validate({ + teams: [ + { + members: [ + { addresses: [{ zip: '12345' }] }, + { addresses: [{ zip: '12345' }, { zip: '1' }] }, + ], + }, + ], + }); + assert.strictEqual(result.valid, false); + assert.strictEqual( + result.errors[0].path, + 'teams[0].members[1].addresses[1].zip', + ); + assert.strictEqual(result.errors[0].code, 'STRING_TOO_SHORT'); +}); + +test('multiple errors at different deep paths', () => { + const schema = new Schema({ + type: 'object', + properties: { + outer: { + type: 'object', + required: ['x', 'y'], + properties: { + x: { + type: 'object', + properties: { + value: { type: 'number', minimum: 0 }, + }, + }, + y: { + type: 'object', + properties: { + value: { type: 'string', minLength: 3 }, + }, + }, + }, + }, + }, + }); + + const result = schema.validate({ + outer: { + x: { value: -5 }, + y: { value: 'a' }, + }, + }); + assert.strictEqual(result.valid, false); + const paths = result.errors.map((e) => e.path); + assert.ok(paths.includes('outer.x.value')); + assert.ok(paths.includes('outer.y.value')); +}); + +test('deeply nested applyDefaults fills missing leaves', () => { + const schema = new Schema({ + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { + type: 'object', + properties: { + c: { + type: 'object', + properties: { + d: { type: 'string', default: 'deep' }, + }, + }, + }, + }, + }, + }, + }, + }); + + const result = schema.applyDefaults({ a: { b: { c: {} } } }); + assert.strictEqual(result.a.b.c.d, 'deep'); +}); diff --git a/test/parallel/test-validator-defaults.js b/test/parallel/test-validator-defaults.js new file mode 100644 index 00000000000000..7642045fe7eb47 --- /dev/null +++ b/test/parallel/test-validator-defaults.js @@ -0,0 +1,114 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('applyDefaults - applies defaults for missing properties', () => { + const schema = new Schema({ + type: 'object', + properties: { + host: { type: 'string', default: 'localhost' }, + port: { type: 'integer', default: 3000 }, + }, + }); + const result = schema.applyDefaults({}); + assert.strictEqual(result.host, 'localhost'); + assert.strictEqual(result.port, 3000); +}); + +test('applyDefaults - does not overwrite existing values', () => { + const schema = new Schema({ + type: 'object', + properties: { + host: { type: 'string', default: 'localhost' }, + }, + }); + const result = schema.applyDefaults({ host: 'example.com' }); + assert.strictEqual(result.host, 'example.com'); +}); + +test('applyDefaults - does not mutate input', () => { + const schema = new Schema({ + type: 'object', + properties: { + host: { type: 'string', default: 'localhost' }, + }, + }); + const input = {}; + schema.applyDefaults(input); + assert.strictEqual(input.host, undefined); +}); + +test('applyDefaults - applies for undefined values', () => { + const schema = new Schema({ + type: 'object', + properties: { + host: { type: 'string', default: 'localhost' }, + }, + }); + const result = schema.applyDefaults({ host: undefined }); + assert.strictEqual(result.host, 'localhost'); +}); + +test('applyDefaults - does not apply for null values', () => { + const schema = new Schema({ + type: 'object', + properties: { + host: { type: 'string', default: 'localhost' }, + }, + }); + const result = schema.applyDefaults({ host: null }); + assert.strictEqual(result.host, null); +}); + +test('applyDefaults - nested defaults', () => { + const schema = new Schema({ + type: 'object', + properties: { + config: { + type: 'object', + properties: { + timeout: { type: 'integer', default: 5000 }, + retries: { type: 'integer', default: 3 }, + }, + }, + }, + }); + const result = schema.applyDefaults({ config: { retries: 1 } }); + assert.strictEqual(result.config.timeout, 5000); + assert.strictEqual(result.config.retries, 1); +}); + +test('applyDefaults - preserves extra properties', () => { + const schema = new Schema({ + type: 'object', + properties: { + name: { type: 'string', default: 'unknown' }, + }, + }); + const result = schema.applyDefaults({ extra: 'value' }); + assert.strictEqual(result.name, 'unknown'); + assert.strictEqual(result.extra, 'value'); +}); + +test('applyDefaults - returns non-object data as-is', () => { + const schema = new Schema({ type: 'string' }); + assert.strictEqual(schema.applyDefaults('hello'), 'hello'); +}); + +test('applyDefaults - array items defaults', () => { + const schema = new Schema({ + type: 'array', + items: { + type: 'object', + properties: { + enabled: { type: 'boolean', default: true }, + }, + }, + }); + const result = schema.applyDefaults([{ enabled: false }, {}]); + assert.strictEqual(result[0].enabled, false); + assert.strictEqual(result[1].enabled, true); +}); diff --git a/test/parallel/test-validator-disabled.js b/test/parallel/test-validator-disabled.js new file mode 100644 index 00000000000000..b56a08db53276f --- /dev/null +++ b/test/parallel/test-validator-disabled.js @@ -0,0 +1,29 @@ +'use strict'; + +const { spawnPromisified } = require('../common'); +const { test } = require('node:test'); + +test('node:validator cannot be accessed without the node: scheme', (t) => { + t.assert.throws(() => { + require('validator'); + }, { + code: 'MODULE_NOT_FOUND', + message: /Cannot find module 'validator'/, + }); +}); + +test('node:validator is disabled by --no-experimental-validator', async (t) => { + const { stdout, stderr, code, signal } = await spawnPromisified( + process.execPath, + [ + '--no-experimental-validator', + '-e', + 'require("node:validator")', + ], + ); + + t.assert.strictEqual(stdout, ''); + t.assert.match(stderr, /No such built-in module: node:validator/); + t.assert.notStrictEqual(code, 0); + t.assert.strictEqual(signal, null); +}); diff --git a/test/parallel/test-validator-errors.js b/test/parallel/test-validator-errors.js new file mode 100644 index 00000000000000..cb7ab1c0cb3d08 --- /dev/null +++ b/test/parallel/test-validator-errors.js @@ -0,0 +1,132 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema, codes } = require('node:validator'); + +test('INVALID_TYPE produced for wrong type', () => { + const schema = new Schema({ type: 'string' }); + const result = schema.validate(42); + assert.strictEqual(result.errors[0].code, codes.INVALID_TYPE); +}); + +test('MISSING_REQUIRED produced for missing property', () => { + const schema = new Schema({ + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }); + const result = schema.validate({}); + assert.strictEqual(result.errors[0].code, codes.MISSING_REQUIRED); +}); + +test('STRING_TOO_SHORT produced', () => { + const schema = new Schema({ type: 'string', minLength: 5 }); + const result = schema.validate('hi'); + assert.strictEqual(result.errors[0].code, codes.STRING_TOO_SHORT); +}); + +test('STRING_TOO_LONG produced', () => { + const schema = new Schema({ type: 'string', maxLength: 2 }); + const result = schema.validate('hello'); + assert.strictEqual(result.errors[0].code, codes.STRING_TOO_LONG); +}); + +test('PATTERN_MISMATCH produced', () => { + const schema = new Schema({ type: 'string', pattern: '^[0-9]+$' }); + const result = schema.validate('abc'); + assert.strictEqual(result.errors[0].code, codes.PATTERN_MISMATCH); +}); + +test('ENUM_MISMATCH produced', () => { + const schema = new Schema({ type: 'string', enum: ['a', 'b'] }); + const result = schema.validate('c'); + assert.strictEqual(result.errors[0].code, codes.ENUM_MISMATCH); +}); + +test('NUMBER_TOO_SMALL produced', () => { + const schema = new Schema({ type: 'number', minimum: 10 }); + const result = schema.validate(5); + assert.strictEqual(result.errors[0].code, codes.NUMBER_TOO_SMALL); +}); + +test('NUMBER_TOO_LARGE produced', () => { + const schema = new Schema({ type: 'number', maximum: 10 }); + const result = schema.validate(15); + assert.strictEqual(result.errors[0].code, codes.NUMBER_TOO_LARGE); +}); + +test('NUMBER_NOT_MULTIPLE produced', () => { + const schema = new Schema({ type: 'number', multipleOf: 3 }); + const result = schema.validate(7); + assert.strictEqual(result.errors[0].code, codes.NUMBER_NOT_MULTIPLE); +}); + +test('NOT_INTEGER produced', () => { + const schema = new Schema({ type: 'integer' }); + const result = schema.validate(3.14); + assert.strictEqual(result.errors[0].code, codes.NOT_INTEGER); +}); + +test('ARRAY_TOO_SHORT produced', () => { + const schema = new Schema({ type: 'array', minItems: 2 }); + const result = schema.validate([1]); + assert.strictEqual(result.errors[0].code, codes.ARRAY_TOO_SHORT); +}); + +test('ARRAY_TOO_LONG produced', () => { + const schema = new Schema({ type: 'array', maxItems: 1 }); + const result = schema.validate([1, 2]); + assert.strictEqual(result.errors[0].code, codes.ARRAY_TOO_LONG); +}); + +test('ADDITIONAL_PROPERTY produced', () => { + const schema = new Schema({ + type: 'object', + additionalProperties: false, + properties: { a: { type: 'string' } }, + }); + const result = schema.validate({ a: 'ok', b: 'extra' }); + assert.strictEqual(result.errors[0].code, codes.ADDITIONAL_PROPERTY); +}); + +test('multiple errors collected', () => { + const schema = new Schema({ + type: 'object', + required: ['a', 'b'], + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + c: { type: 'boolean' }, + }, + }); + const result = schema.validate({ c: 'not_boolean' }); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.length >= 3); +}); + +test('error path uses dot notation', () => { + const schema = new Schema({ + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { type: 'string' }, + }, + }, + }, + }); + const result = schema.validate({ a: { b: 42 } }); + assert.strictEqual(result.errors[0].path, 'a.b'); +}); + +test('error path uses bracket notation for arrays', () => { + const schema = new Schema({ + type: 'array', + items: { type: 'string' }, + }); + const result = schema.validate(['ok', 42]); + assert.strictEqual(result.errors[0].path, '[1]'); +}); diff --git a/test/parallel/test-validator-esm.mjs b/test/parallel/test-validator-esm.mjs new file mode 100644 index 00000000000000..25150c5c3aa07f --- /dev/null +++ b/test/parallel/test-validator-esm.mjs @@ -0,0 +1,25 @@ +import '../common/index.mjs'; +import assert from 'node:assert'; +import { suite, test } from 'node:test'; + +const { Schema, codes } = await import('node:validator'); + +suite('node:validator ESM support', () => { + test('Schema is importable and functional', () => { + const schema = new Schema({ type: 'string' }); + const result = schema.validate('hello'); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors.length, 0); + }); + + test('codes are accessible', () => { + assert.strictEqual(codes.INVALID_TYPE, 'INVALID_TYPE'); + }); + + test('validation errors work', () => { + const schema = new Schema({ type: 'number', minimum: 10 }); + const result = schema.validate(5); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, codes.NUMBER_TOO_SMALL); + }); +}); diff --git a/test/parallel/test-validator-experimental-warning.js b/test/parallel/test-validator-experimental-warning.js new file mode 100644 index 00000000000000..d5f61736831dc2 --- /dev/null +++ b/test/parallel/test-validator-experimental-warning.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); + +common.expectWarning( + 'ExperimentalWarning', + 'validator is an experimental feature and might change at any time', +); + +const validator = require('node:validator'); + +test('Schema is usable after the experimental warning fires', () => { + const schema = new validator.Schema({ type: 'string', minLength: 1 }); + assert.strictEqual(schema.validate('ok').valid, true); + const bad = schema.validate(''); + assert.strictEqual(bad.valid, false); + assert.strictEqual(bad.errors[0].code, 'STRING_TOO_SHORT'); +}); + +test('codes export is accessible and populated', () => { + assert.strictEqual(typeof validator.codes, 'object'); + assert.strictEqual(validator.codes.INVALID_TYPE, 'INVALID_TYPE'); + assert.strictEqual(validator.codes.STRING_TOO_SHORT, 'STRING_TOO_SHORT'); +}); diff --git a/test/parallel/test-validator-nested.js b/test/parallel/test-validator-nested.js new file mode 100644 index 00000000000000..a283279f3548c1 --- /dev/null +++ b/test/parallel/test-validator-nested.js @@ -0,0 +1,128 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('nested object validation', () => { + const schema = new Schema({ + type: 'object', + properties: { + address: { + type: 'object', + required: ['city'], + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + }, + }, + }); + + assert.strictEqual( + schema.validate({ address: { street: '123 Main', city: 'NYC' } }).valid, + true); + + const result = schema.validate({ address: { street: 123 } }); + assert.strictEqual(result.valid, false); + const paths = result.errors.map((e) => e.path); + assert.ok(paths.includes('address.street')); + assert.ok(paths.includes('address.city')); +}); + +test('array of objects', () => { + const schema = new Schema({ + type: 'array', + items: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }, + }); + + assert.strictEqual( + schema.validate([{ name: 'Alice' }, { name: 'Bob' }]).valid, true); + + const result = schema.validate([{ name: 'Alice' }, {}]); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, '[1].name'); + assert.strictEqual(result.errors[0].code, 'MISSING_REQUIRED'); +}); + +test('deeply nested error paths', () => { + const schema = new Schema({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + contact: { + type: 'object', + properties: { + email: { type: 'string', pattern: '^[^@]+@[^@]+$' }, + }, + }, + }, + }, + }, + }, + }); + + const result = schema.validate({ + users: [ + { contact: { email: 'valid@test.com' } }, + { contact: { email: 'invalid' } }, + ], + }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, 'users[1].contact.email'); +}); + +test('three-level nesting', () => { + const schema = new Schema({ + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { + type: 'object', + properties: { + c: { type: 'string' }, + }, + }, + }, + }, + }, + }); + const result = schema.validate({ a: { b: { c: 42 } } }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, 'a.b.c'); +}); + +test('nested object missing required in nested', () => { + const schema = new Schema({ + type: 'object', + required: ['config'], + properties: { + config: { + type: 'object', + required: ['host', 'port'], + properties: { + host: { type: 'string' }, + port: { type: 'integer' }, + }, + }, + }, + }); + + const result = schema.validate({ config: { host: 'localhost' } }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, 'config.port'); + assert.strictEqual(result.errors[0].code, 'MISSING_REQUIRED'); +}); diff --git a/test/parallel/test-validator-number.js b/test/parallel/test-validator-number.js new file mode 100644 index 00000000000000..2913140a72b279 --- /dev/null +++ b/test/parallel/test-validator-number.js @@ -0,0 +1,120 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('minimum - passes at boundary', () => { + const schema = new Schema({ type: 'number', minimum: 0 }); + assert.strictEqual(schema.validate(0).valid, true); +}); + +test('minimum - fails below', () => { + const schema = new Schema({ type: 'number', minimum: 0 }); + const result = schema.validate(-1); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NUMBER_TOO_SMALL'); +}); + +test('maximum - passes at boundary', () => { + const schema = new Schema({ type: 'number', maximum: 100 }); + assert.strictEqual(schema.validate(100).valid, true); +}); + +test('maximum - fails above', () => { + const schema = new Schema({ type: 'number', maximum: 100 }); + const result = schema.validate(101); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NUMBER_TOO_LARGE'); +}); + +test('exclusiveMinimum - fails at boundary', () => { + const schema = new Schema({ type: 'number', exclusiveMinimum: 0 }); + const result = schema.validate(0); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NUMBER_TOO_SMALL'); +}); + +test('exclusiveMinimum - passes above', () => { + const schema = new Schema({ type: 'number', exclusiveMinimum: 0 }); + assert.strictEqual(schema.validate(0.001).valid, true); +}); + +test('exclusiveMaximum - fails at boundary', () => { + const schema = new Schema({ type: 'number', exclusiveMaximum: 100 }); + const result = schema.validate(100); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NUMBER_TOO_LARGE'); +}); + +test('exclusiveMaximum - passes below', () => { + const schema = new Schema({ type: 'number', exclusiveMaximum: 100 }); + assert.strictEqual(schema.validate(99.999).valid, true); +}); + +test('multipleOf - multiples pass', () => { + const schema = new Schema({ type: 'number', multipleOf: 3 }); + assert.strictEqual(schema.validate(0).valid, true); + assert.strictEqual(schema.validate(9).valid, true); + assert.strictEqual(schema.validate(-6).valid, true); +}); + +test('multipleOf - non-multiples fail', () => { + const schema = new Schema({ type: 'number', multipleOf: 3 }); + const result = schema.validate(7); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NUMBER_NOT_MULTIPLE'); +}); + +test('multipleOf - floating point', () => { + const schema = new Schema({ type: 'number', multipleOf: 0.1 }); + assert.strictEqual(schema.validate(0.3).valid, true); + assert.strictEqual(schema.validate(0.7).valid, true); +}); + +test('Infinity rejected', () => { + const schema = new Schema({ type: 'number' }); + assert.strictEqual(schema.validate(Infinity).valid, false); + assert.strictEqual(schema.validate(-Infinity).valid, false); +}); + +test('NaN rejected', () => { + const schema = new Schema({ type: 'number' }); + assert.strictEqual(schema.validate(NaN).valid, false); +}); + +test('-0 satisfies minimum: 0 and maximum: 0', () => { + const minSchema = new Schema({ type: 'number', minimum: 0 }); + assert.strictEqual(minSchema.validate(-0).valid, true); + + const maxSchema = new Schema({ type: 'number', maximum: 0 }); + assert.strictEqual(maxSchema.validate(-0).valid, true); +}); + +test('undefined with number constraints yields INVALID_TYPE, not a crash', () => { + const schema = new Schema({ type: 'number', minimum: 0, maximum: 100 }); + const result = schema.validate(undefined); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.errors[0].code, 'INVALID_TYPE'); +}); + +test('combined number constraints', () => { + const schema = new Schema({ + type: 'number', + minimum: 0, + maximum: 100, + multipleOf: 5, + }); + assert.strictEqual(schema.validate(25).valid, true); + assert.strictEqual(schema.validate(0).valid, true); + assert.strictEqual(schema.validate(100).valid, true); + + const r1 = schema.validate(-5); + assert.strictEqual(r1.valid, false); + + const r2 = schema.validate(7); + assert.strictEqual(r2.valid, false); + assert.strictEqual(r2.errors[0].code, 'NUMBER_NOT_MULTIPLE'); +}); diff --git a/test/parallel/test-validator-object.js b/test/parallel/test-validator-object.js new file mode 100644 index 00000000000000..6470f62d4f7b94 --- /dev/null +++ b/test/parallel/test-validator-object.js @@ -0,0 +1,156 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('properties - validates named properties', () => { + const schema = new Schema({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }); + assert.strictEqual(schema.validate({ name: 'Alice', age: 30 }).valid, true); +}); + +test('properties - reports property errors', () => { + const schema = new Schema({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }); + const result = schema.validate({ name: 42, age: 'thirty' }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 2); + assert.strictEqual(result.errors[0].path, 'name'); + assert.strictEqual(result.errors[1].path, 'age'); +}); + +test('required - missing required fails', () => { + const schema = new Schema({ + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }); + const result = schema.validate({ age: 30 }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'MISSING_REQUIRED'); + assert.strictEqual(result.errors[0].path, 'name'); +}); + +test('required - present required passes', () => { + const schema = new Schema({ + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + }, + }); + assert.strictEqual(schema.validate({ name: 'Alice' }).valid, true); +}); + +test('optional properties can be omitted', () => { + const schema = new Schema({ + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }); + assert.strictEqual(schema.validate({ name: 'Alice' }).valid, true); +}); + +test('additionalProperties false - extra properties fail', () => { + const schema = new Schema({ + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string' }, + }, + }); + const result = schema.validate({ name: 'Alice', extra: true }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'ADDITIONAL_PROPERTY'); + assert.strictEqual(result.errors[0].path, 'extra'); +}); + +test('additionalProperties true (default) - extra properties pass', () => { + const schema = new Schema({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }); + assert.strictEqual(schema.validate({ name: 'Alice', extra: true }).valid, true); +}); + +test('multiple required fields missing', () => { + const schema = new Schema({ + type: 'object', + required: ['a', 'b', 'c'], + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + c: { type: 'string' }, + }, + }); + const result = schema.validate({}); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 3); +}); + +test('empty object with no constraints is valid', () => { + const schema = new Schema({ type: 'object', properties: {} }); + assert.strictEqual(schema.validate({}).valid, true); +}); + +test('property name containing "." produces literal dot in path', () => { + // Documents current behavior: formatPath joins with a literal '.', + // so a property named 'a.b' produces path 'a.b'. A future path-escape + // change would flip this assertion. + const schema = new Schema({ + type: 'object', + properties: { + 'a.b': { type: 'string' }, + }, + }); + assert.strictEqual(schema.validate({ 'a.b': 'ok' }).valid, true); + const result = schema.validate({ 'a.b': 42 }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, 'a.b'); + assert.strictEqual(result.errors[0].code, 'INVALID_TYPE'); +}); + +test('property name containing "[" is preserved verbatim in path', () => { + const schema = new Schema({ + type: 'object', + properties: { + 'items[0]': { type: 'string' }, + }, + }); + assert.strictEqual(schema.validate({ 'items[0]': 'ok' }).valid, true); + const result = schema.validate({ 'items[0]': 42 }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].path, 'items[0]'); +}); + +test('additionalProperties false without properties rejects all keys', () => { + const schema = new Schema({ + type: 'object', + additionalProperties: false, + }); + assert.strictEqual(schema.validate({}).valid, true); + const result = schema.validate({ a: 1 }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'ADDITIONAL_PROPERTY'); + assert.strictEqual(result.errors[0].path, 'a'); +}); diff --git a/test/parallel/test-validator-pattern-runtime.js b/test/parallel/test-validator-pattern-runtime.js new file mode 100644 index 00000000000000..b9fccff68c7055 --- /dev/null +++ b/test/parallel/test-validator-pattern-runtime.js @@ -0,0 +1,99 @@ +'use strict'; + +// Runtime behavior of compiled pattern constraints. Schema construction +// already validates that the source is a valid RegExp; these tests cover what +// happens when that compiled pattern is applied to real input across many +// calls, edge strings, and reused Schema instances. + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('repeated validation is stable (no lastIndex leak)', () => { + // If the internal RegExp were ever compiled with the `g` flag, its + // `lastIndex` would carry over across calls and validation would flip + // between pass and fail on the same input. Pin the stateless contract. + const schema = new Schema({ type: 'string', pattern: '^[a-z]+$' }); + for (let i = 0; i < 5; i++) { + assert.strictEqual(schema.validate('hello').valid, true); + } + for (let i = 0; i < 5; i++) { + assert.strictEqual(schema.validate('Hello').valid, false); + } +}); + +test('same Schema instance reused across different values', () => { + const schema = new Schema({ type: 'string', pattern: '\\d+' }); + const inputs = [ + ['abc123', true], + ['no-digits', false], + ['42', true], + ['', false], + ]; + for (const [value, expected] of inputs) { + assert.strictEqual(schema.validate(value).valid, expected); + } +}); + +test('pattern anchors are not multiline by default', () => { + // Patterns are compiled with no flags. `^`/`$` match start/end of input, + // not start/end of line. Pin that behavior so adding the `m` flag becomes + // a conscious decision rather than a silent change. + const schema = new Schema({ type: 'string', pattern: '^abc$' }); + assert.strictEqual(schema.validate('abc').valid, true); + assert.strictEqual(schema.validate('abc\n').valid, false); + assert.strictEqual(schema.validate('\nabc').valid, false); +}); + +test('pattern dot does not match newline', () => { + // Default RegExp semantics: `.` does not match '\n'. Pin it so adding the + // `s` (dotAll) flag would be a conscious change. + const schema = new Schema({ type: 'string', pattern: '^a.b$' }); + assert.strictEqual(schema.validate('aXb').valid, true); + assert.strictEqual(schema.validate('a\nb').valid, false); +}); + +test('pattern error message includes the original source', () => { + const schema = new Schema({ type: 'string', pattern: '^[A-Z]+$' }); + const result = schema.validate('lower'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'PATTERN_MISMATCH'); + assert.ok(result.errors[0].message.includes('^[A-Z]+$')); +}); + +test('pattern with backreference at runtime', () => { + const schema = new Schema({ type: 'string', pattern: '^(.)\\1$' }); + assert.strictEqual(schema.validate('aa').valid, true); + assert.strictEqual(schema.validate('ab').valid, false); +}); + +test('pattern with lookahead at runtime', () => { + const schema = new Schema({ + type: 'string', + pattern: '^(?=.*\\d)(?=.*[a-z]).+$', + }); + assert.strictEqual(schema.validate('abc1').valid, true); + assert.strictEqual(schema.validate('abc').valid, false); + assert.strictEqual(schema.validate('123').valid, false); +}); + +test('pattern against empty string', () => { + const schema = new Schema({ type: 'string', pattern: '.+' }); + const result = schema.validate(''); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'PATTERN_MISMATCH'); +}); + +test('pattern compiled once is reused for every item in an array', () => { + const schema = new Schema({ + type: 'array', + items: { type: 'string', pattern: '^[a-z]+$' }, + }); + const result = schema.validate(['good', 'BAD', 'also', '123']); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 2); + const paths = result.errors.map((e) => e.path); + assert.ok(paths.includes('[1]')); + assert.ok(paths.includes('[3]')); +}); diff --git a/test/parallel/test-validator-schema-basic.js b/test/parallel/test-validator-schema-basic.js new file mode 100644 index 00000000000000..7bdbd3aca66806 --- /dev/null +++ b/test/parallel/test-validator-schema-basic.js @@ -0,0 +1,170 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const util = require('util'); +const { test } = require('node:test'); + +test('require node:validator works', () => { + const validator = require('node:validator'); + assert.ok(validator); + assert.ok(validator.Schema); + assert.ok(validator.codes); +}); + +test('require validator without scheme throws', () => { + assert.throws( + () => require('validator'), + { code: 'MODULE_NOT_FOUND' }, + ); +}); + +test('Schema constructor with valid definition', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'string' }); + assert.ok(schema); +}); + +test('Schema constructor throws for non-object', () => { + const { Schema } = require('node:validator'); + assert.throws(() => new Schema('string'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => new Schema(null), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => new Schema(42), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => new Schema([1, 2]), { + code: 'ERR_INVALID_ARG_TYPE', + }); +}); + +test('validate returns correct structure', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'string' }); + const result = schema.validate('hello'); + assert.strictEqual(typeof result.valid, 'boolean'); + assert.ok(Array.isArray(result.errors)); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors.length, 0); +}); + +test('validate result is frozen', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'string' }); + const result = schema.validate('hello'); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.errors)); +}); + +test('toJSON returns a frozen copy of the definition', () => { + const { Schema } = require('node:validator'); + const definition = { type: 'string', minLength: 1 }; + const schema = new Schema(definition); + const json = schema.toJSON(); + assert.deepStrictEqual(json, definition); + assert.ok(Object.isFrozen(json)); + assert.strictEqual(schema.toJSON(), json); +}); + +test('codes export has all expected codes', () => { + const { codes } = require('node:validator'); + assert.strictEqual(codes.INVALID_TYPE, 'INVALID_TYPE'); + assert.strictEqual(codes.MISSING_REQUIRED, 'MISSING_REQUIRED'); + assert.strictEqual(codes.STRING_TOO_SHORT, 'STRING_TOO_SHORT'); + assert.strictEqual(codes.STRING_TOO_LONG, 'STRING_TOO_LONG'); + assert.strictEqual(codes.PATTERN_MISMATCH, 'PATTERN_MISMATCH'); + assert.strictEqual(codes.ENUM_MISMATCH, 'ENUM_MISMATCH'); + assert.strictEqual(codes.NUMBER_TOO_SMALL, 'NUMBER_TOO_SMALL'); + assert.strictEqual(codes.NUMBER_TOO_LARGE, 'NUMBER_TOO_LARGE'); + assert.strictEqual(codes.NUMBER_NOT_MULTIPLE, 'NUMBER_NOT_MULTIPLE'); + assert.strictEqual(codes.NOT_INTEGER, 'NOT_INTEGER'); + assert.strictEqual(codes.ARRAY_TOO_SHORT, 'ARRAY_TOO_SHORT'); + assert.strictEqual(codes.ARRAY_TOO_LONG, 'ARRAY_TOO_LONG'); + assert.strictEqual(codes.ADDITIONAL_PROPERTY, 'ADDITIONAL_PROPERTY'); +}); + +test('codes export is frozen', () => { + const { codes } = require('node:validator'); + assert.ok(Object.isFrozen(codes)); +}); + +test('toJSON preserves -0 (not coerced to 0)', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'number', default: -0 }); + const json = schema.toJSON(); + assert.strictEqual(Object.is(json.default, -0), true); +}); + +test('toJSON keeps `default` by reference', () => { + const { Schema } = require('node:validator'); + const defaults = { nested: { a: 1 } }; + const schema = new Schema({ type: 'object', default: defaults }); + assert.strictEqual(schema.toJSON().default, defaults); +}); + +test('toJSON result is deeply frozen', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }); + const json = schema.toJSON(); + assert.ok(Object.isFrozen(json)); + assert.ok(Object.isFrozen(json.properties)); + assert.ok(Object.isFrozen(json.properties.name)); + assert.ok(Object.isFrozen(json.properties.tags)); + assert.ok(Object.isFrozen(json.properties.tags.items)); + assert.ok(Object.isFrozen(json.required)); +}); + +test('circular schema throws ERR_VALIDATOR_INVALID_SCHEMA', () => { + const { Schema } = require('node:validator'); + const def = { type: 'object', properties: {} }; + def.properties.self = def; + assert.throws(() => new Schema(def), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('Schema instance has Symbol.toStringTag "Schema"', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'string' }); + assert.strictEqual( + Object.prototype.toString.call(schema), '[object Schema]'); +}); + +test('util.inspect(schema) output starts with "Schema "', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'string', minLength: 1 }); + const output = util.inspect(schema); + assert.ok(output.startsWith('Schema '), + `expected output to start with "Schema ", got: ${output}`); +}); + +test('util.inspect(schema, { depth: -1 }) returns "Schema [Object]"', () => { + const { Schema } = require('node:validator'); + const schema = new Schema({ type: 'string' }); + assert.strictEqual(util.inspect(schema, { depth: -1 }), 'Schema [Object]'); +}); + +test('shared-but-acyclic subgraph does not false-trigger circular check', () => { + const { Schema } = require('node:validator'); + // Same leaf schema object referenced under two different property names. + // The seen-set must be path-local, not global, or this throws. + const leaf = { type: 'string' }; + const schema = new Schema({ + type: 'object', + properties: { + a: leaf, + b: leaf, + }, + }); + assert.strictEqual(schema.validate({ a: 'x', b: 'y' }).valid, true); +}); diff --git a/test/parallel/test-validator-schema-validation.js b/test/parallel/test-validator-schema-validation.js new file mode 100644 index 00000000000000..604be5a0955b58 --- /dev/null +++ b/test/parallel/test-validator-schema-validation.js @@ -0,0 +1,190 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('invalid type string', () => { + assert.throws(() => new Schema({ type: 'invalid' }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('missing type', () => { + assert.throws(() => new Schema({}), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('negative minLength', () => { + assert.throws(() => new Schema({ type: 'string', minLength: -1 }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('non-integer minLength', () => { + assert.throws(() => new Schema({ type: 'string', minLength: 1.5 }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('minLength greater than maxLength', () => { + assert.throws( + () => new Schema({ type: 'string', minLength: 10, maxLength: 5 }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('invalid pattern regex', () => { + assert.throws(() => new Schema({ type: 'string', pattern: '[invalid' }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('pattern not a string', () => { + assert.throws(() => new Schema({ type: 'string', pattern: 123 }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('enum not an array', () => { + assert.throws(() => new Schema({ type: 'string', enum: 'red' }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('enum empty array', () => { + assert.throws(() => new Schema({ type: 'string', enum: [] }), { + code: 'ERR_VALIDATOR_INVALID_SCHEMA', + }); +}); + +test('required not an array', () => { + assert.throws( + () => new Schema({ + type: 'object', required: 'name', + properties: { name: { type: 'string' } }, + }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('required contains non-strings', () => { + assert.throws( + () => new Schema({ + type: 'object', required: [123], + properties: { 123: { type: 'string' } }, + }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('properties not an object', () => { + assert.throws( + () => new Schema({ type: 'object', properties: 'bad' }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('properties is an array', () => { + assert.throws( + () => new Schema({ type: 'object', properties: [] }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('items not a schema object', () => { + assert.throws( + () => new Schema({ type: 'array', items: 'string' }), + { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('additionalProperties not boolean', () => { + assert.throws( + () => new Schema({ + type: 'object', additionalProperties: 'no', + properties: {}, + }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('multipleOf must be positive', () => { + assert.throws( + () => new Schema({ type: 'number', multipleOf: 0 }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); + assert.throws( + () => new Schema({ type: 'number', multipleOf: -1 }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('minimum must be finite number', () => { + assert.throws( + () => new Schema({ type: 'number', minimum: 'zero' }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); + assert.throws( + () => new Schema({ type: 'number', minimum: Infinity }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('minimum rejects NaN', () => { + assert.throws( + () => new Schema({ type: 'number', minimum: NaN }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('maximum rejects Infinity', () => { + assert.throws( + () => new Schema({ type: 'number', maximum: Infinity }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('maximum rejects NaN', () => { + assert.throws( + () => new Schema({ type: 'number', maximum: NaN }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('exclusiveMinimum rejects Infinity', () => { + assert.throws( + () => new Schema({ type: 'number', exclusiveMinimum: Infinity }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('exclusiveMaximum rejects -Infinity', () => { + assert.throws( + () => new Schema({ type: 'number', exclusiveMaximum: -Infinity }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('multipleOf rejects NaN', () => { + assert.throws( + () => new Schema({ type: 'number', multipleOf: NaN }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('multipleOf rejects Infinity', () => { + assert.throws( + () => new Schema({ type: 'number', multipleOf: Infinity }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('minItems greater than maxItems', () => { + assert.throws( + () => new Schema({ type: 'array', minItems: 10, maxItems: 5 }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('unknown constraint for type', () => { + assert.throws( + () => new Schema({ type: 'string', minimum: 0 }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); + assert.throws( + () => new Schema({ type: 'number', minLength: 0 }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('required property not in properties', () => { + assert.throws( + () => new Schema({ + type: 'object', + required: ['missing'], + properties: { name: { type: 'string' } }, + }), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); diff --git a/test/parallel/test-validator-static.js b/test/parallel/test-validator-static.js new file mode 100644 index 00000000000000..64e5e294697b45 --- /dev/null +++ b/test/parallel/test-validator-static.js @@ -0,0 +1,73 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('Schema.validate convenience method - valid', () => { + const result = Schema.validate({ type: 'string' }, 'hello'); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors.length, 0); +}); + +test('Schema.validate convenience method - invalid', () => { + const result = Schema.validate({ type: 'number', minimum: 0 }, -1); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NUMBER_TOO_SMALL'); +}); + +test('Schema.validate equivalent to new Schema().validate()', () => { + const definition = { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1 }, + age: { type: 'integer', minimum: 0 }, + }, + }; + const data = { name: '', age: -1 }; + + const staticResult = Schema.validate(definition, data); + const instanceResult = new Schema(definition).validate(data); + + assert.strictEqual(staticResult.valid, instanceResult.valid); + assert.strictEqual(staticResult.errors.length, instanceResult.errors.length); + for (let i = 0; i < staticResult.errors.length; i++) { + assert.strictEqual(staticResult.errors[i].path, instanceResult.errors[i].path); + assert.strictEqual(staticResult.errors[i].code, instanceResult.errors[i].code); + } +}); + +test('Schema.validate throws for invalid schema', () => { + assert.throws( + () => Schema.validate({ type: 'invalid' }, 'data'), + { code: 'ERR_VALIDATOR_INVALID_SCHEMA' }); +}); + +test('Schema.validate with complex schema', () => { + const result = Schema.validate({ + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'integer', minimum: 1 }, + label: { type: 'string' }, + }, + }, + }, + }, + }, { + items: [ + { id: 1, label: 'first' }, + { id: 2, label: 'second' }, + ], + }); + assert.strictEqual(result.valid, true); +}); diff --git a/test/parallel/test-validator-string.js b/test/parallel/test-validator-string.js new file mode 100644 index 00000000000000..f2cb0d621c9aae --- /dev/null +++ b/test/parallel/test-validator-string.js @@ -0,0 +1,103 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('minLength - passes at boundary', () => { + const schema = new Schema({ type: 'string', minLength: 3 }); + assert.strictEqual(schema.validate('abc').valid, true); +}); + +test('minLength - fails below', () => { + const schema = new Schema({ type: 'string', minLength: 3 }); + const result = schema.validate('ab'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'STRING_TOO_SHORT'); +}); + +test('minLength - zero allows empty string', () => { + const schema = new Schema({ type: 'string', minLength: 0 }); + assert.strictEqual(schema.validate('').valid, true); +}); + +test('maxLength - passes at boundary', () => { + const schema = new Schema({ type: 'string', maxLength: 5 }); + assert.strictEqual(schema.validate('abcde').valid, true); +}); + +test('maxLength - fails above', () => { + const schema = new Schema({ type: 'string', maxLength: 5 }); + const result = schema.validate('abcdef'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'STRING_TOO_LONG'); +}); + +test('pattern - matches', () => { + const schema = new Schema({ type: 'string', pattern: '^[a-z]+$' }); + assert.strictEqual(schema.validate('hello').valid, true); +}); + +test('pattern - mismatches', () => { + const schema = new Schema({ type: 'string', pattern: '^[a-z]+$' }); + const result = schema.validate('Hello123'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'PATTERN_MISMATCH'); +}); + +test('pattern - email-like', () => { + const schema = new Schema({ type: 'string', pattern: '^[^@]+@[^@]+$' }); + assert.strictEqual(schema.validate('user@example.com').valid, true); + assert.strictEqual(schema.validate('invalid').valid, false); +}); + +test('enum - value in list passes', () => { + const schema = new Schema({ type: 'string', enum: ['red', 'green', 'blue'] }); + assert.strictEqual(schema.validate('red').valid, true); + assert.strictEqual(schema.validate('green').valid, true); +}); + +test('enum - value not in list fails', () => { + const schema = new Schema({ type: 'string', enum: ['red', 'green', 'blue'] }); + const result = schema.validate('yellow'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'ENUM_MISMATCH'); +}); + +test('minLength === maxLength pins exact length', () => { + const schema = new Schema({ type: 'string', minLength: 5, maxLength: 5 }); + assert.strictEqual(schema.validate('abcde').valid, true); + + const tooShort = schema.validate('abcd'); + assert.strictEqual(tooShort.valid, false); + assert.strictEqual(tooShort.errors[0].code, 'STRING_TOO_SHORT'); + + const tooLong = schema.validate('abcdef'); + assert.strictEqual(tooLong.valid, false); + assert.strictEqual(tooLong.errors[0].code, 'STRING_TOO_LONG'); +}); + +test('combined constraints - all pass', () => { + const schema = new Schema({ + type: 'string', + minLength: 1, + maxLength: 10, + pattern: '^[a-z]+$', + }); + assert.strictEqual(schema.validate('hello').valid, true); +}); + +test('combined constraints - multiple failures', () => { + const schema = new Schema({ + type: 'string', + minLength: 5, + pattern: '^[0-9]+$', + }); + const result = schema.validate('ab'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors.length, 2); + const errorCodes = result.errors.map((e) => e.code); + assert.ok(errorCodes.includes('STRING_TOO_SHORT')); + assert.ok(errorCodes.includes('PATTERN_MISMATCH')); +}); diff --git a/test/parallel/test-validator-types.js b/test/parallel/test-validator-types.js new file mode 100644 index 00000000000000..d9bbd5c53f04ab --- /dev/null +++ b/test/parallel/test-validator-types.js @@ -0,0 +1,126 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('type string - valid', () => { + const schema = new Schema({ type: 'string' }); + assert.strictEqual(schema.validate('hello').valid, true); + assert.strictEqual(schema.validate('').valid, true); +}); + +test('type string - invalid', () => { + const schema = new Schema({ type: 'string' }); + assert.strictEqual(schema.validate(42).valid, false); + assert.strictEqual(schema.validate(true).valid, false); + assert.strictEqual(schema.validate(null).valid, false); + assert.strictEqual(schema.validate(undefined).valid, false); + assert.strictEqual(schema.validate([]).valid, false); + assert.strictEqual(schema.validate({}).valid, false); +}); + +test('type number - valid', () => { + const schema = new Schema({ type: 'number' }); + assert.strictEqual(schema.validate(42).valid, true); + assert.strictEqual(schema.validate(3.14).valid, true); + assert.strictEqual(schema.validate(0).valid, true); + assert.strictEqual(schema.validate(-1).valid, true); +}); + +test('type number - invalid', () => { + const schema = new Schema({ type: 'number' }); + assert.strictEqual(schema.validate('42').valid, false); + assert.strictEqual(schema.validate(true).valid, false); + assert.strictEqual(schema.validate(null).valid, false); + assert.strictEqual(schema.validate(NaN).valid, false); + assert.strictEqual(schema.validate(undefined).valid, false); +}); + +test('type integer - valid', () => { + const schema = new Schema({ type: 'integer' }); + assert.strictEqual(schema.validate(42).valid, true); + assert.strictEqual(schema.validate(0).valid, true); + assert.strictEqual(schema.validate(-5).valid, true); +}); + +test('type integer - rejects floats', () => { + const schema = new Schema({ type: 'integer' }); + const result = schema.validate(3.14); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'NOT_INTEGER'); +}); + +test('type integer - rejects non-numbers', () => { + const schema = new Schema({ type: 'integer' }); + assert.strictEqual(schema.validate('42').valid, false); + assert.strictEqual(schema.validate(NaN).valid, false); +}); + +test('type boolean - valid', () => { + const schema = new Schema({ type: 'boolean' }); + assert.strictEqual(schema.validate(true).valid, true); + assert.strictEqual(schema.validate(false).valid, true); +}); + +test('type boolean - invalid', () => { + const schema = new Schema({ type: 'boolean' }); + assert.strictEqual(schema.validate(0).valid, false); + assert.strictEqual(schema.validate(1).valid, false); + assert.strictEqual(schema.validate('true').valid, false); + assert.strictEqual(schema.validate(null).valid, false); +}); + +test('type null - valid', () => { + const schema = new Schema({ type: 'null' }); + assert.strictEqual(schema.validate(null).valid, true); +}); + +test('type null - invalid', () => { + const schema = new Schema({ type: 'null' }); + assert.strictEqual(schema.validate(undefined).valid, false); + assert.strictEqual(schema.validate(0).valid, false); + assert.strictEqual(schema.validate('').valid, false); + assert.strictEqual(schema.validate(false).valid, false); +}); + +test('type object - valid', () => { + const schema = new Schema({ type: 'object', properties: {} }); + assert.strictEqual(schema.validate({}).valid, true); + assert.strictEqual(schema.validate({ a: 1 }).valid, true); +}); + +test('type object - rejects non-objects', () => { + const schema = new Schema({ type: 'object', properties: {} }); + assert.strictEqual(schema.validate([]).valid, false); + assert.strictEqual(schema.validate(null).valid, false); + assert.strictEqual(schema.validate('string').valid, false); + assert.strictEqual(schema.validate(42).valid, false); +}); + +test('type array - valid', () => { + const schema = new Schema({ type: 'array' }); + assert.strictEqual(schema.validate([]).valid, true); + assert.strictEqual(schema.validate([1, 2, 3]).valid, true); +}); + +test('type array - rejects non-arrays', () => { + const schema = new Schema({ type: 'array' }); + assert.strictEqual(schema.validate({}).valid, false); + assert.strictEqual(schema.validate('hello').valid, false); + assert.strictEqual(schema.validate(42).valid, false); + assert.strictEqual(schema.validate(null).valid, false); +}); + +test('error objects have correct structure', () => { + const schema = new Schema({ type: 'string' }); + const result = schema.validate(42); + assert.strictEqual(result.errors.length, 1); + const error = result.errors[0]; + assert.strictEqual(typeof error.path, 'string'); + assert.strictEqual(typeof error.message, 'string'); + assert.strictEqual(typeof error.code, 'string'); + assert.strictEqual(error.code, 'INVALID_TYPE'); + assert.strictEqual(error.path, ''); +}); diff --git a/test/parallel/test-validator-unicode.js b/test/parallel/test-validator-unicode.js new file mode 100644 index 00000000000000..8a871ed0687ae3 --- /dev/null +++ b/test/parallel/test-validator-unicode.js @@ -0,0 +1,84 @@ +'use strict'; + +// String length constraints in node:validator count UTF-16 code units, like +// JavaScript's `String.prototype.length`. These tests pin that behavior so +// surrogate-pair handling doesn't silently change. + +require('../common'); +const assert = require('assert'); +const { test } = require('node:test'); +const { Schema } = require('node:validator'); + +test('surrogate pair counts as two code units for minLength', () => { + const schema = new Schema({ type: 'string', minLength: 2 }); + // 'šŸŽ‰' is a surrogate pair: .length === 2 + assert.strictEqual(schema.validate('šŸŽ‰').valid, true); +}); + +test('surrogate pair counts as two code units for maxLength', () => { + const schema = new Schema({ type: 'string', maxLength: 1 }); + const result = schema.validate('šŸŽ‰'); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.errors[0].code, 'STRING_TOO_LONG'); +}); + +test('BMP code point counts as one code unit', () => { + const schema = new Schema({ type: 'string', minLength: 1, maxLength: 1 }); + assert.strictEqual(schema.validate('Ć©').valid, true); + assert.strictEqual(schema.validate('ę¼¢').valid, true); +}); + +test('combining characters each count separately', () => { + // 'Ć©' composed as 'e' + U+0301 is length 2 in UTF-16. + const composed = 'e\u0301'; + const schema = new Schema({ type: 'string', minLength: 2, maxLength: 2 }); + assert.strictEqual(schema.validate(composed).valid, true); +}); + +test('pattern with unicode literal matches', () => { + const schema = new Schema({ type: 'string', pattern: '^漢字$' }); + assert.strictEqual(schema.validate('漢字').valid, true); + assert.strictEqual(schema.validate('hello').valid, false); +}); + +test('pattern with character class and unicode', () => { + const schema = new Schema({ type: 'string', pattern: '^[a-zĆ©]+$' }); + assert.strictEqual(schema.validate('cafĆ©').valid, true); +}); + +test('enum with unicode strings', () => { + const schema = new Schema({ + type: 'string', + enum: ['ę—„ęœ¬', 'ķ•œźµ­', 'ΕλλάΓα'], + }); + assert.strictEqual(schema.validate('ę—„ęœ¬').valid, true); + assert.strictEqual(schema.validate('China').valid, false); +}); + +test('property name with non-ASCII characters', () => { + const schema = new Schema({ + type: 'object', + required: ['名前'], + properties: { + 名前: { type: 'string' }, + }, + }); + assert.strictEqual(schema.validate({ 名前: 'Alice' }).valid, true); + const missing = schema.validate({}); + assert.strictEqual(missing.valid, false); + assert.strictEqual(missing.errors[0].path, '名前'); + assert.strictEqual(missing.errors[0].code, 'MISSING_REQUIRED'); +}); + +test('empty string is valid with minLength 0', () => { + const schema = new Schema({ type: 'string', minLength: 0, maxLength: 10 }); + assert.strictEqual(schema.validate('').valid, true); +}); + +test('pattern does not anchor implicitly', () => { + // The validator compiles `new RegExp(source)` — no anchors. + // Pattern "cat" should match anywhere in the string. + const schema = new Schema({ type: 'string', pattern: 'cat' }); + assert.strictEqual(schema.validate('concatenate').valid, true); + assert.strictEqual(schema.validate('dog').valid, false); +}); From 945435052a48a670256797a181609b51f01465b1 Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Fri, 17 Apr 2026 11:30:25 +0200 Subject: [PATCH 3/4] doc(validator): validator documentation --- doc/api/cli.md | 10 ++ doc/api/errors.md | 15 ++ doc/api/index.md | 1 + doc/api/validator.md | 323 ++++++++++++++++++++++++++++++++++++ doc/node-config-schema.json | 4 + doc/type-map.json | 4 +- 6 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 doc/api/validator.md diff --git a/doc/api/cli.md b/doc/api/cli.md index e979ec95c4259d..9030d0545a8112 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -2013,6 +2013,14 @@ changes: Disable the experimental [`node:sqlite`][] module. +### `--no-experimental-validator` + + + +Disable the experimental [`node:validator`][] module. + ### `--no-experimental-websocket` + +Thrown by the [`node:validator`][] [`Schema`][] constructor when the schema +definition is malformed — for example, an unknown `type`, a constraint that +does not apply to the declared type, an invalid regular expression in +`pattern`, or a `required` entry not listed in `properties`. + ### `ERR_VALID_PERFORMANCE_ENTRY_TYPE` @@ -4471,6 +4484,7 @@ An error occurred trying to allocate memory. This should never happen. [`new URL(input)`]: url.md#new-urlinput-base [`new URLPattern(input)`]: url.md#new-urlpatternstring-baseurl-options [`new URLSearchParams(iterable)`]: url.md#new-urlsearchparamsiterable +[`node:validator`]: validator.md [`package.json`]: packages.md#nodejs-packagejson-field-definitions [`postMessage()`]: worker_threads.md#portpostmessagevalue-transferlist [`postMessageToThread()`]: worker_threads.md#worker_threadspostmessagetothreadthreadid-value-transferlist-timeout @@ -4480,6 +4494,7 @@ An error occurred trying to allocate memory. This should never happen. [`readable._read()`]: stream.md#readable_readsize [`require('node:crypto').setEngine()`]: crypto.md#cryptosetengineengine-flags [`require()`]: modules.md#requireid +[`Schema`]: validator.md#class-schema [`server.close()`]: net.md#serverclosecallback [`server.listen()`]: net.md#serverlisten [`sign.sign()`]: crypto.md#signsignprivatekey-outputencoding diff --git a/doc/api/index.md b/doc/api/index.md index 1c9cc02c6d80a7..82f72b47a9de9b 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -65,6 +65,7 @@ * [URL](url.md) * [Utilities](util.md) * [V8](v8.md) +* [Validator](validator.md) * [VM](vm.md) * [WASI](wasi.md) * [Web Crypto API](webcrypto.md) diff --git a/doc/api/validator.md b/doc/api/validator.md new file mode 100644 index 00000000000000..3cb8b83b34a918 --- /dev/null +++ b/doc/api/validator.md @@ -0,0 +1,323 @@ +# Validator + + + + + +> Stability: 1.0 - Early development + +> This feature is experimental and may change at any time. To disable it, +> start Node.js with [`--no-experimental-validator`][]. + + + +The `node:validator` module provides a schema-based object validator for +simple REST API input validation. It supports basic type checking, property +constraints, required fields, and nested schemas. + +To access it: + +```mjs +import { Schema } from 'node:validator'; +``` + +```cjs +const { Schema } = require('node:validator'); +``` + +This module is only available under the `node:` scheme. + +The following example shows the basic usage of the `node:validator` module +to validate a user object. + +```mjs +import { Schema } from 'node:validator'; + +const userSchema = new Schema({ + type: 'object', + required: ['name', 'email'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + email: { type: 'string', pattern: '^[^@]+@[^@]+$' }, + age: { type: 'integer', minimum: 0, maximum: 150 }, + tags: { type: 'array', items: { type: 'string' }, maxItems: 10 }, + }, +}); + +const result = userSchema.validate({ + name: 'Alice', + email: 'alice@example.com', + age: 30, + tags: ['admin'], +}); + +console.log(result.valid); // true +console.log(result.errors); // [] +``` + +```cjs +'use strict'; +const { Schema } = require('node:validator'); + +const userSchema = new Schema({ + type: 'object', + required: ['name', 'email'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + email: { type: 'string', pattern: '^[^@]+@[^@]+$' }, + age: { type: 'integer', minimum: 0, maximum: 150 }, + tags: { type: 'array', items: { type: 'string' }, maxItems: 10 }, + }, +}); + +const result = userSchema.validate({ + name: 'Alice', + email: 'alice@example.com', + age: 30, + tags: ['admin'], +}); + +console.log(result.valid); // true +console.log(result.errors); // [] +``` + +## Validation error codes + + + +The following error codes are returned in the `code` field of validation +error objects. They are also available as properties of the `codes` export. + +| Code | Description | +| ---- | ----------- | +| `INVALID_TYPE` | Value type does not match the declared type | +| `MISSING_REQUIRED` | A required property is missing | +| `STRING_TOO_SHORT` | String is shorter than `minLength` | +| `STRING_TOO_LONG` | String is longer than `maxLength` | +| `PATTERN_MISMATCH` | String does not match the `pattern` regex | +| `ENUM_MISMATCH` | Value is not one of the allowed `enum` values | +| `NUMBER_TOO_SMALL` | Number is below `minimum` or `exclusiveMinimum` | +| `NUMBER_TOO_LARGE` | Number is above `maximum` or `exclusiveMaximum` | +| `NUMBER_NOT_MULTIPLE` | Number is not a multiple of `multipleOf` | +| `NOT_INTEGER` | Value is not an integer when `type` is `'integer'` | +| `ARRAY_TOO_SHORT` | Array has fewer items than `minItems` | +| `ARRAY_TOO_LONG` | Array has more items than `maxItems` | +| `ADDITIONAL_PROPERTY` | Object has a property not listed in `properties` when `additionalProperties` is `false` | + +Compare the `code` field of a validation error against the `codes` export +instead of hard-coding string literals: + +```cjs +const { Schema, codes } = require('node:validator'); + +const schema = new Schema({ type: 'number', minimum: 0 }); +const result = schema.validate(-1); +if (!result.valid && result.errors[0].code === codes.NUMBER_TOO_SMALL) { + // Handle the below-minimum case. +} +``` + +## Class: `Schema` + + + +### `new Schema(definition)` + + + +* `definition` {Object} A schema definition object. + +Creates a new `Schema` instance. The schema definition is validated and +compiled at construction time. [`ERR_VALIDATOR_INVALID_SCHEMA`][] is thrown +if the definition is invalid. + +The `definition` object must have a `type` property set to one of the +supported types: `'string'`, `'number'`, `'integer'`, `'boolean'`, +`'object'`, `'array'`, or `'null'`. + +#### Schema definition properties + +The following properties are supported depending on the schema type. + +##### All types + +* `type` {string} **Required.** One of `'string'`, `'number'`, `'integer'`, + `'boolean'`, `'object'`, `'array'`, `'null'`. +* `default` {any} Default value to apply when using + [`schema.applyDefaults()`][]. + +##### Type: `'string'` + +* `minLength` {number} Minimum string length (inclusive). Must be a + non-negative integer. +* `maxLength` {number} Maximum string length (inclusive). Must be a + non-negative integer. +* `pattern` {string} A regular expression pattern the string must match. +* `enum` {Array} An array of allowed values. + +##### Type: `'number'` + +* `minimum` {number} Minimum value (inclusive). +* `maximum` {number} Maximum value (inclusive). +* `exclusiveMinimum` {number} Minimum value (exclusive). +* `exclusiveMaximum` {number} Maximum value (exclusive). +* `multipleOf` {number} The value must be a multiple of this number. Must be + greater than 0. + +##### Type: `'integer'` + +Same constraints as `'number'`. Additionally, the value must be an integer. + +##### Type: `'array'` + +* `items` {Object} A schema definition for array elements. +* `minItems` {number} Minimum array length (inclusive). Must be a + non-negative integer. +* `maxItems` {number} Maximum array length (inclusive). Must be a + non-negative integer. + +##### Type: `'object'` + +* `properties` {Object} A map of property names to schema definitions. +* `required` {string\[]} An array of required property names. Each name must + be defined in `properties`. +* `additionalProperties` {boolean} Whether properties not listed in + `properties` are allowed. **Default:** `true`. + +### `schema.validate(data)` + + + +* `data` {any} The value to validate. +* Returns: {ValidationResult} + +Validates the given data against the schema. Returns a frozen object with +`valid` and `errors` properties. Validation never throws; all validation +failures are returned in the `errors` array. + +```cjs +const { Schema } = require('node:validator'); +const schema = new Schema({ type: 'string', minLength: 1 }); + +const good = schema.validate('hello'); +console.log(good.valid); // true + +const bad = schema.validate(''); +console.log(bad.valid); // false +console.log(bad.errors[0].code); // 'STRING_TOO_SHORT' +``` + +### `schema.applyDefaults(data)` + + + +* `data` {any} The data object to apply defaults to. +* Returns: {Object} A new object with defaults applied. + +Returns a new object with default values applied for missing or `undefined` +properties. The input data is not mutated. Defaults are applied recursively +for nested object schemas. + +```cjs +const { Schema } = require('node:validator'); +const schema = new Schema({ + type: 'object', + properties: { + host: { type: 'string', default: 'localhost' }, + port: { type: 'integer', default: 3000 }, + }, +}); + +const config = schema.applyDefaults({}); +console.log(config.host); // 'localhost' +console.log(config.port); // 3000 +``` + +### `schema.toJSON()` + + + +* Returns: {Object} A frozen copy of the original schema definition. + +Returns the original schema definition as a frozen plain object. This is +useful for composing schemas — pass the result as a sub-schema definition +in another schema: + +```cjs +const { Schema } = require('node:validator'); +const addressSchema = new Schema({ + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, +}); + +const userSchema = new Schema({ + type: 'object', + properties: { + name: { type: 'string' }, + address: addressSchema.toJSON(), + }, +}); +``` + +### Static method: `Schema.validate(definition, data)` + + + +* `definition` {Object} A schema definition object. +* `data` {any} The value to validate. +* Returns: {ValidationResult} + +Convenience method that creates a `Schema` and validates in one call. +Equivalent to `new Schema(definition).validate(data)`. The definition is +compiled on every invocation; when validating repeatedly against the same +schema, reuse a single `new Schema()` instance instead. + +```cjs +const { Schema } = require('node:validator'); +const result = Schema.validate({ type: 'number', minimum: 0 }, 42); +console.log(result.valid); // true +``` + +## Type: `ValidationResult` + + + +The object returned by [`schema.validate()`][]. It is a frozen plain object +with the following properties: + +* `valid` {boolean} `true` if the data matches the schema. +* `errors` {Object\[]} A frozen array of error objects. Empty when `valid` + is `true`. Each error object has the following properties: + * `path` {string} The path to the invalid value using dot notation for + object properties and bracket notation for array indices. The root + path is an empty string. + * `message` {string} A human-readable error description. + * `code` {string} A machine-readable error code from the + [validation error codes][] table. + +[`--no-experimental-validator`]: cli.md#--no-experimental-validator +[`ERR_VALIDATOR_INVALID_SCHEMA`]: errors.md#err_validator_invalid_schema +[`schema.applyDefaults()`]: #schemaapplydefaultsdata +[`schema.validate()`]: #schemavalidatedata +[validation error codes]: #validation-error-codes diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json index d33c73e9b4c556..418c02ed03618d 100644 --- a/doc/node-config-schema.json +++ b/doc/node-config-schema.json @@ -221,6 +221,10 @@ "type": "boolean", "description": "experimental node:sqlite module" }, + "experimental-validator": { + "type": "boolean", + "description": "experimental node:validator module" + }, "experimental-vm-modules": { "type": "boolean", "description": "experimental ES Module support in vm module" diff --git a/doc/type-map.json b/doc/type-map.json index 4741264f60e46f..a81b9b351bb2f6 100644 --- a/doc/type-map.json +++ b/doc/type-map.json @@ -123,5 +123,7 @@ "zlib options": "zlib.html#class-options", "zstd options": "zlib.html#class-zstdoptions", "HTTP/2 Headers Object": "http2.html#headers-object", - "HTTP/2 Settings Object": "http2.html#settings-object" + "HTTP/2 Settings Object": "http2.html#settings-object", + "Schema": "validator.html#class-schema", + "ValidationResult": "validator.html#type-validationresult" } From 6766f00a87c057d5dfc0e7bf3caa5bc16eef78a1 Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Sat, 25 Apr 2026 10:27:17 +0200 Subject: [PATCH 4/4] fixup: linter on documentation --- doc/api/validator.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/doc/api/validator.md b/doc/api/validator.md index 3cb8b83b34a918..000e7c00eae9b9 100644 --- a/doc/api/validator.md +++ b/doc/api/validator.md @@ -92,20 +92,20 @@ added: REPLACEME The following error codes are returned in the `code` field of validation error objects. They are also available as properties of the `codes` export. -| Code | Description | -| ---- | ----------- | -| `INVALID_TYPE` | Value type does not match the declared type | -| `MISSING_REQUIRED` | A required property is missing | -| `STRING_TOO_SHORT` | String is shorter than `minLength` | -| `STRING_TOO_LONG` | String is longer than `maxLength` | -| `PATTERN_MISMATCH` | String does not match the `pattern` regex | -| `ENUM_MISMATCH` | Value is not one of the allowed `enum` values | -| `NUMBER_TOO_SMALL` | Number is below `minimum` or `exclusiveMinimum` | -| `NUMBER_TOO_LARGE` | Number is above `maximum` or `exclusiveMaximum` | -| `NUMBER_NOT_MULTIPLE` | Number is not a multiple of `multipleOf` | -| `NOT_INTEGER` | Value is not an integer when `type` is `'integer'` | -| `ARRAY_TOO_SHORT` | Array has fewer items than `minItems` | -| `ARRAY_TOO_LONG` | Array has more items than `maxItems` | +| Code | Description | +| --------------------- | --------------------------------------------------------------------------------------- | +| `INVALID_TYPE` | Value type does not match the declared type | +| `MISSING_REQUIRED` | A required property is missing | +| `STRING_TOO_SHORT` | String is shorter than `minLength` | +| `STRING_TOO_LONG` | String is longer than `maxLength` | +| `PATTERN_MISMATCH` | String does not match the `pattern` regex | +| `ENUM_MISMATCH` | Value is not one of the allowed `enum` values | +| `NUMBER_TOO_SMALL` | Number is below `minimum` or `exclusiveMinimum` | +| `NUMBER_TOO_LARGE` | Number is above `maximum` or `exclusiveMaximum` | +| `NUMBER_NOT_MULTIPLE` | Number is not a multiple of `multipleOf` | +| `NOT_INTEGER` | Value is not an integer when `type` is `'integer'` | +| `ARRAY_TOO_SHORT` | Array has fewer items than `minItems` | +| `ARRAY_TOO_LONG` | Array has more items than `maxItems` | | `ADDITIONAL_PROPERTY` | Object has a property not listed in `properties` when `additionalProperties` is `false` | Compare the `code` field of a validation error against the `codes` export