tynder
Version:
TypeScript friendly Data validator for JavaScript.
870 lines (771 loc) • 29.4 kB
text/typescript
// Copyright (c) 2019 Shellyl_N and Authors
// license: ISC
// https://github.com/shellyln
import { ErrorTypes,
NeverTypeAssertion,
AnyTypeAssertion,
UnknownTypeAssertion,
PrimitiveTypeAssertion,
PrimitiveValueTypeAssertion,
RepeatedAssertion,
SequenceAssertion,
SpreadAssertion,
OptionalAssertion,
OneOfAssertion,
EnumAssertion,
ObjectAssertion,
TypeAssertion,
ValidationContext,
TypeAssertionMap,
Stereotype,
CustomConstraintInfo } from './types';
import { ValidationError } from './lib/errors';
import { NumberPattern } from './lib/util';
import { isUnsafeVarNames } from './lib/protection';
import { reportError,
reportErrorWithPush } from './lib/reporter';
import { resolveSymbols } from './lib/resolver';
import { noopStereotype } from './stereotypes/noop';
function checkStereotypes(
data: any, ty: TypeAssertion, ctx: ValidationContext):
{value: any, stereotype: Stereotype} | null | false {
if (ty.stereotype && ctx.stereotypes) {
if (ctx.stereotypes.has(ty.stereotype)) {
const stereotype = ctx.stereotypes.get(ty.stereotype) as Stereotype;
const parsed = stereotype.tryParse(data);
if (parsed) {
return ({
value: parsed.value,
stereotype,
});
} else {
return null;
}
} else {
throw new Error(`Undefined stereotype is specified: ${ty.stereotype}`);
}
}
return false;
}
function forceCast(
targetType:
'number' | 'integer' | 'bigint' | 'string' | 'boolean' | 'undefined' | 'null' |
'symbol' | 'object' | 'function',
value: any) {
switch (targetType) {
case 'number':
if (typeof value === 'number') {
return value;
} else {
const a = Number.parseFloat(String(value));
if (Number.isNaN(a)) {
return Number(value ?? 0);
} else {
return a;
}
}
case 'integer':
if (typeof value === 'number' && Math.trunc(value) === value) {
return value;
} else {
let a = Number.parseFloat(String(value));
if (Number.isNaN(a)) {
a = Number(value ?? 0);
}
return Math.trunc(a);
}
case 'bigint':
try {
return BigInt(value ?? 0);
} catch {
return NaN;
}
case 'string':
return String(value);
case 'boolean':
return Boolean(value);
case 'undefined':
return void 0;
case 'null':
return null;
default:
return value;
}
}
function checkCustomConstraints(
data: any, ty: TypeAssertion, ctx: ValidationContext): boolean | null {
if (ty.customConstraints && ctx.customConstraints) {
for (const ccName of ty.customConstraints) {
if (ctx.customConstraints.has(ccName)) {
const cc = ctx.customConstraints.get(ccName) as CustomConstraintInfo;
if (cc.kinds && !cc.kinds.includes(ty.kind)) {
return null;
}
if (! cc.check(data, ty.customConstraintsArgs && ty.customConstraintsArgs[ccName])) {
return null;
}
} else {
throw new Error(`Undefined constraint is specified: ${ccName}`);
}
}
return true;
}
return false;
}
function validateNeverTypeAssertion<T>(
data: any, ty: NeverTypeAssertion, ctx: ValidationContext): null {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
function validateAnyTypeAssertion<T>(
data: any, ty: AnyTypeAssertion, ctx: ValidationContext): {value: T} | null {
let chkSt = checkStereotypes(data, ty, ctx);
if (chkSt === null) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
} else if (chkSt === false) {
chkSt = {
value: data,
stereotype: noopStereotype,
};
}
const styp = chkSt.stereotype;
if (checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
}
// always matched
return ({value: ctx.mapper
? ctx.mapper(styp.doCast ? chkSt.value : data, ty)
: styp.doCast ? chkSt.value : data});
}
function validateUnknownTypeAssertion<T>(
data: any, ty: UnknownTypeAssertion, ctx: ValidationContext): {value: T} | null {
let chkSt = checkStereotypes(data, ty, ctx);
if (chkSt === null) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
} else if (chkSt === false) {
chkSt = {
value: data,
stereotype: noopStereotype,
};
}
const styp = chkSt.stereotype;
if (checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
}
// always matched
return ({value: ctx.mapper
? ctx.mapper(styp.doCast ? chkSt.value : data, ty)
: styp.doCast ? chkSt.value : data});
}
function validatePrimitiveTypeAssertion<T>(
data: any, ty: PrimitiveTypeAssertion, ctx: ValidationContext): {value: T} | null {
const chkTarget = ty.forceCast ? forceCast(ty.primitiveName, data) : data;
if (ty.primitiveName === 'null') {
if (chkTarget !== null) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
} else if (ty.primitiveName === 'integer') {
if (typeof chkTarget !== 'number') {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
if (Math.trunc(chkTarget) !== chkTarget) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
} else if (typeof chkTarget !== ty.primitiveName) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
// TODO: Function, DateStr, DateTimeStr
let chkSt = checkStereotypes(chkTarget, ty, ctx);
if (chkSt === null) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
} else if (chkSt === false) {
chkSt = {
value: chkTarget,
stereotype: ty.forceCast ? noopStereotype : noopStereotype,
};
}
const styVal = chkSt.value;
const styp = chkSt.stereotype;
let err = false;
let valueRangeErr = false;
switch (typeof ty.minValue) {
case 'number': case 'string': // TODO: bigint
if (styp.compare(styVal, styp.evaluateFormula(ty.minValue)) < 0) {
if (! valueRangeErr) {
reportError(ErrorTypes.ValueRangeUnmatched, data, ty, {ctx});
}
valueRangeErr = true;
err = true;
}
}
switch (typeof ty.maxValue) {
case 'number': case 'string': // TODO: bigint
if (styp.compare(styVal, styp.evaluateFormula(ty.maxValue)) > 0) {
if (! valueRangeErr) {
reportError(ErrorTypes.ValueRangeUnmatched, data, ty, {ctx});
}
valueRangeErr = true;
err = true;
}
}
switch (typeof ty.greaterThanValue) {
case 'number': case 'string': // TODO: bigint
if (styp.compare(styVal, styp.evaluateFormula(ty.greaterThanValue)) <= 0) {
if (! valueRangeErr) {
reportError(ErrorTypes.ValueRangeUnmatched, data, ty, {ctx});
}
valueRangeErr = true;
err = true;
}
}
switch (typeof ty.lessThanValue) {
case 'number': case 'string': // TODO: bigint
if (styp.compare(styVal, styp.evaluateFormula(ty.lessThanValue)) >= 0) {
if (! valueRangeErr) {
reportError(ErrorTypes.ValueRangeUnmatched, data, ty, {ctx});
}
valueRangeErr = true;
err = true;
}
}
let valueLengthErr = false;
switch (typeof ty.minLength) {
case 'number':
if (typeof styVal !== 'string' || styVal.length < ty.minLength) {
if (! valueLengthErr) {
reportError(ErrorTypes.ValueLengthUnmatched, data, ty, {ctx});
}
valueLengthErr = true;
err = true;
}
}
switch (typeof ty.maxLength) {
case 'number':
if (typeof styVal !== 'string' || styVal.length > ty.maxLength) {
if (! valueLengthErr) {
reportError(ErrorTypes.ValueLengthUnmatched, data, ty, {ctx});
}
valueLengthErr = true;
err = true;
}
}
if (ty.pattern) {
if (typeof styVal !== 'string' || !ty.pattern.test(styVal)) {
reportError(ErrorTypes.ValuePatternUnmatched, data, ty, {ctx});
err = true;
}
}
if (checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
err = true;
}
const ret = !err
? {value: ctx.mapper
? ctx.mapper(styp.doCast ? chkSt.value : chkTarget, ty)
: styp.doCast ? chkSt.value : chkTarget}
: null;
return ret;
}
function validatePrimitiveValueTypeAssertion<T>(
data: any, ty: PrimitiveValueTypeAssertion, ctx: ValidationContext): {value: T} | null {
const chkTarget = ty.forceCast ? forceCast(typeof ty.value, data) : data;
let chkSt = checkStereotypes(chkTarget, ty, ctx);
if (chkSt === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
} else if (chkSt === false) {
chkSt = {
value: chkTarget,
stereotype: ty.forceCast ? noopStereotype : noopStereotype,
};
}
const styp = chkSt.stereotype;
let ret = styp.compare(chkSt.value, styp.evaluateFormula(ty.value)) === 0
? {value: ctx.mapper
? ctx.mapper(styp.doCast ? chkSt.value : chkTarget, ty)
: styp.doCast ? chkSt.value : chkTarget}
: null;
if (! ret) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
}
if (ret && checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
ret = null;
}
return ret;
}
function validateRepeatedAssertion<T>(
data: any, ty: RepeatedAssertion, ctx: ValidationContext): {value: T} | null {
if (! Array.isArray(data)) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
if (typeof ty.min === 'number' && data.length < ty.min) {
reportError(ErrorTypes.RepeatQtyUnmatched, data, ty, {ctx});
return null;
}
if (typeof ty.max === 'number' && data.length > ty.max) {
reportError(ErrorTypes.RepeatQtyUnmatched, data, ty, {ctx});
return null;
}
const retVals: any[] = [];
for (let i = 0; i < data.length; i++) {
const x = data[i];
const r = validateRoot<T>(x, ty.repeated, ctx, i);
if (! r) {
return null;
}
retVals.push(r.value);
}
if (checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
}
return {value: retVals as any};
}
function validateSequenceAssertion<T>(
data: any, ty: SequenceAssertion, ctx: ValidationContext): {value: T} | null {
if (! Array.isArray(data)) {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
return null;
}
let dIdx = 0, // index of data
sIdx = 0; // index of types
let spreadLen = 0;
let optionalOmitted = false;
const checkSpreadQuantity = (ts: SpreadAssertion, index: number) => {
if (typeof ts.min === 'number' && spreadLen < ts.min) {
reportErrorWithPush(
spreadLen === 0 ?
ErrorTypes.TypeUnmatched :
ErrorTypes.RepeatQtyUnmatched, data, [ts, index], {ctx});
return null;
}
if (typeof ts.max === 'number' && spreadLen > ts.max) {
reportErrorWithPush(ErrorTypes.RepeatQtyUnmatched, data, [ts, index], {ctx});
return null;
}
return ts;
};
const checkOptionalQuantity = (ts: OptionalAssertion, index: number) => {
if (spreadLen === 0) {
// All subsequent 'optional' assertions should be 'spreadLen === 0'.
optionalOmitted = true;
} else if (optionalOmitted) {
reportErrorWithPush(ErrorTypes.RepeatQtyUnmatched, data, [ts, index], {ctx});
return null;
} else if (spreadLen > 1) {
reportErrorWithPush(ErrorTypes.RepeatQtyUnmatched, data, [ts, index], {ctx});
return null;
}
return ts;
};
const retVals: any[] = [];
while (dIdx < data.length && sIdx < ty.sequence.length) {
const ts = ty.sequence[sIdx];
if (ts.kind === 'spread') {
const savedErrLen = ctx.errors.length;
const r = validateRoot<T>(data[dIdx], ts.spread, ctx, dIdx);
if (r) {
retVals.push(r.value);
dIdx++;
spreadLen++;
} else {
// End of spreading
// rollback reported errors
ctx.errors.length = savedErrLen;
if (! checkSpreadQuantity(ts, dIdx)) {
return null;
}
spreadLen = 0;
sIdx++;
}
} else if (ts.kind === 'optional') {
const savedErrLen = ctx.errors.length;
const r = validateRoot<T>(data[dIdx], ts.optional, ctx, dIdx);
if (r) {
retVals.push(r.value);
dIdx++;
spreadLen++;
} else {
// End of spreading
// rollback reported errors
ctx.errors.length = savedErrLen;
if (! checkOptionalQuantity(ts, dIdx)) {
return null;
}
spreadLen = 0;
sIdx++;
}
} else {
const r = validateRoot<T>(data[dIdx], ts, ctx, dIdx);
if (r) {
retVals.push(r.value);
dIdx++;
sIdx++;
} else {
return null;
}
}
}
while (sIdx < ty.sequence.length) {
const ts = ty.sequence[sIdx];
if (ts.kind === 'spread') {
if (! checkSpreadQuantity(ts, dIdx)) {
return null;
}
spreadLen = 0;
sIdx++;
} else if (ts.kind === 'optional') {
if (! checkOptionalQuantity(ts, dIdx)) {
return null;
}
spreadLen = 0;
sIdx++;
} else {
reportErrorWithPush(ErrorTypes.RepeatQtyUnmatched, data, [ts, dIdx], {ctx});
return null;
}
}
const ret = data.length === dIdx ? {value: retVals as any} : null;
if (! ret) {
reportError(ErrorTypes.SequenceUnmatched, data, ty, {ctx});
}
if (ret && checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
}
return ret;
}
function validateOneOfAssertion<T>(
data: any, ty: OneOfAssertion, ctx: ValidationContext): {value: T} | null {
let choosed = false;
const savedCtxRecordTypeFieldValidated = ctx.recordTypeFieldValidated;
ctx.recordTypeFieldValidated = false;
const savedErrLen = ctx.errors.length;
let count = 0;
let firstErrLen = savedErrLen;
for (const tyOne of ty.oneOf) {
const r = validateRoot<T>(data, tyOne, ctx);
if (r) {
// rollback reported errors
ctx.errors.length = savedErrLen;
ctx.recordTypeFieldValidated = savedCtxRecordTypeFieldValidated;
return r;
}
if (ctx.recordTypeFieldValidated) {
if (count !== 0) {
const e2 = ctx.errors.slice(firstErrLen);
ctx.errors.length = savedErrLen;
ctx.errors.push(...e2);
}
choosed = true;
break;
}
if (count === 0) {
firstErrLen = ctx.errors.length;
} else {
ctx.errors.length = firstErrLen;
}
count++;
}
if (! choosed) {
if (! ctx.checkAll) {
// rollback reported errors
ctx.errors.length = savedErrLen;
}
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
}
ctx.recordTypeFieldValidated = savedCtxRecordTypeFieldValidated;
return null;
}
function validateEnumAssertion<T>(
data: any, ty: EnumAssertion, ctx: ValidationContext): {value: T} | null {
for (const v of ty.values) {
if (data === v[1]) {
return ({value: ctx.mapper ? ctx.mapper(data, ty) : data});
}
}
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
}
function validateObjectAssertion<T>(
data: any, ty: ObjectAssertion, ctx: ValidationContext): {value: T} | null {
let retVal = Array.isArray(data) ? [...data] : {...data};
const revMembers = ty.members.slice().reverse();
for (const x of ty.members) {
if (ty.members.find(m => m[0] === x[0]) !== revMembers.find(m => m[0] === x[0])) {
reportError(ErrorTypes.InvalidDefinition, data, ty, {ctx});
throw new ValidationError(
`Duplicated member is found: ${x[0]} in ${ty.name || '(unnamed)'}`, ty, ctx);
}
}
if (data === null || typeof data !== 'object') {
reportError(ErrorTypes.TypeUnmatched, data, ty, {ctx});
if (ctx && ctx.checkAll) {
retVal = null;
} else {
return null;
}
} else {
const dataMembers = new Set<string>();
if (ctx.noAdditionalProps || ty.additionalProps && 0 < ty.additionalProps.length) {
if (! Array.isArray(data)) {
for (const m in data) {
if (Object.prototype.hasOwnProperty.call(data, m)) {
dataMembers.add(m);
}
}
}
}
if (ctx.noAdditionalProps && Array.isArray(data) && 0 < data.length) {
const aps = ty.additionalProps || [];
if (aps.filter(x => x[0].includes('number')).length === 0) {
reportError(ErrorTypes.AdditionalPropUnmatched, data, ty, {
ctx,
substitutions: [['addtionalProps', '[number]']],
});
if (ctx && ctx.checkAll) {
retVal = null;
} else {
return null;
}
}
}
for (const x of ty.members) {
dataMembers.delete(x[0]);
if (Object.prototype.hasOwnProperty.call(data, x[0])) {
const mt = x[1].kind === 'optional' ? // TODO: set name at compile time
{
...x[1].optional,
name: x[0],
message: x[1].message,
messages: x[1].messages,
messageId: x[1].messageId,
} : x[1];
const ret = validateRoot<T>(data[x[0]], mt, ctx);
if (ret) {
if (retVal) {
if (isUnsafeVarNames(retVal, x[0])) {
continue;
}
retVal[x[0]] = ret.value;
if (mt.isRecordTypeField) {
ctx.recordTypeFieldValidated = true;
}
}
} else {
if (ctx && ctx.checkAll) {
retVal = null;
} else {
return null;
}
}
} else {
if (x[1].kind !== 'optional') {
reportErrorWithPush(ErrorTypes.Required, data, [x[1], void 0], {ctx});
if (ctx && ctx.checkAll) {
retVal = null;
} else {
return null;
}
}
}
}
if (ty.additionalProps && 0 < ty.additionalProps.length) {
function* getAdditionalMembers() {
for (const m of dataMembers.values()) {
yield m;
}
if (Array.isArray(data)) {
for (let i = 0; i < data.length; i++) {
yield String(i);
}
}
}
for (const m of getAdditionalMembers()) {
let allowImplicit = false;
const matchedAssertions: TypeAssertion[] = [];
for (const ap of ty.additionalProps) {
for (const pt of ap[0]) {
const at = ap[1];
if (pt === 'number') {
if (NumberPattern.test(m)) {
matchedAssertions.push(at);
}
} else if (pt === 'string') {
matchedAssertions.push(at);
} else {
if (pt.test(m)) {
matchedAssertions.push(at);
}
}
if (at.kind === 'optional') {
allowImplicit = true;
}
}
}
if (matchedAssertions.length === 0) {
if (allowImplicit) {
continue;
}
reportError(ErrorTypes.AdditionalPropUnmatched, data, ty, {
ctx,
substitutions: [['addtionalProps', m]],
});
if (ctx && ctx.checkAll) {
retVal = null;
continue;
} else {
return null;
}
}
dataMembers.delete(m);
let hasError = false;
const savedErrLen = ctx.errors.length;
for (const at of matchedAssertions) {
const ret = validateRoot<T>(data[m], at.kind === 'optional' ?
{
...at.optional,
message: at.message,
messages: at.messages,
messageId: at.messageId,
name: m,
} : {
...at,
name: m,
}, ctx);
if (ret) {
if (retVal) {
hasError = false;
ctx.errors.length = savedErrLen;
if (isUnsafeVarNames(retVal, m)) {
continue;
}
retVal[m] = ret.value;
}
break;
} else {
hasError = true;
}
}
if (hasError) {
if (ctx && ctx.checkAll) {
retVal = null;
} else {
return null;
}
}
}
}
if (ctx.noAdditionalProps && 0 < dataMembers.size) {
reportError(ErrorTypes.AdditionalPropUnmatched, data, ty, {
ctx,
substitutions: [['addtionalProps', Array.from(dataMembers.values()).join(', ')]],
});
if (ctx && ctx.checkAll) {
retVal = null;
} else {
return null;
}
}
}
if (! retVal) {
// TODO: Child is unmatched. reportError?
// TODO: report object's custom error message
}
if (retVal && checkCustomConstraints(data, ty, ctx) === null) {
reportError(ErrorTypes.ValueUnmatched, data, ty, {ctx});
return null;
}
return retVal ? {value: (ctx && ctx.mapper) ? ctx.mapper(retVal, ty) : retVal} : null;
}
export function validateRoot<T>(
data: any, ty: TypeAssertion, ctx: ValidationContext, dataIndex?: number | string): {value: T} | null {
try {
ctx.typeStack.push(
typeof dataIndex === 'number' || typeof dataIndex === 'string' ?
[ty, dataIndex] : ty);
switch (ty.kind) {
case 'never':
return validateNeverTypeAssertion(data, ty, ctx);
case 'any':
return validateAnyTypeAssertion(data, ty, ctx);
case 'unknown':
return validateUnknownTypeAssertion(data, ty, ctx);
case 'primitive':
return validatePrimitiveTypeAssertion(data, ty, ctx);
case 'primitive-value':
return validatePrimitiveValueTypeAssertion(data, ty, ctx);
case 'repeated':
return validateRepeatedAssertion(data, ty, ctx);
case 'sequence':
return validateSequenceAssertion(data, ty, ctx);
case 'one-of':
return validateOneOfAssertion(data, ty, ctx);
case 'enum':
return validateEnumAssertion(data, ty, ctx);
case 'object':
return validateObjectAssertion(data, ty, ctx);
case 'symlink':
if (ctx.schema) {
return validateRoot<T>(data, resolveSymbols(ctx.schema, ty, {nestLevel: 0, symlinkStack: []}), ctx);
}
reportError(ErrorTypes.InvalidDefinition, data, ty, {ctx});
throw new ValidationError(`Unresolved symbol '${ty.symlinkTargetName}' is appeared.`, ty, ctx);
case 'operator':
if (ctx.schema) {
return validateRoot<T>(data, resolveSymbols(ctx.schema, ty, {nestLevel: 0, symlinkStack: []}), ctx);
}
reportError(ErrorTypes.InvalidDefinition, data, ty, {ctx});
throw new ValidationError(`Unresolved type operator is found: ${ty.operator}`, ty, ctx);
case 'spread': case 'optional':
reportError(ErrorTypes.InvalidDefinition, data, ty, {ctx});
throw new ValidationError(`Unexpected type assertion: ${(ty as any).kind}`, ty, ctx);
default:
reportError(ErrorTypes.InvalidDefinition, data, ty, {ctx});
throw new ValidationError(`Unknown type assertion: ${(ty as any).kind}`, ty, ctx);
}
} finally {
ctx.typeStack.pop();
}
}
export function validate<T>(
data: any, ty: TypeAssertion, ctx?: Partial<ValidationContext>): {value: T} | null {
const ctx2: ValidationContext = {
...{errors: [], typeStack: []},
...(ctx || {}),
};
try {
return validateRoot<T>(data, ty, ctx2);
} finally {
if (ctx) {
ctx.errors = ctx2.errors;
}
}
}
export function isType<T>(
data: any, ty: TypeAssertion, ctx?: Partial<ValidationContext>): data is T {
return (!! validate<T>(data, ty, ctx));
}
export function assertType<T>(
data: any, ty: TypeAssertion, ctx?: Partial<ValidationContext>): asserts data is T {
if (! validate<T>(data, ty, ctx)) {
throw new Error(`Assertion failed: Expected data should be of type "${ty.typeName || ty.name || '?'}".`);
}
}
export function getType(schema: TypeAssertionMap, name: string): TypeAssertion {
if (schema.has(name)) {
return schema.get(name)?.ty as TypeAssertion;
}
throw new Error(`Undefined type name is referred: ${name}`);
}