From 18f55dd8012ce813372bc2de0ef74be66ada864f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:46:27 +0000 Subject: [PATCH 1/4] Initial plan From 5507119f85c7c9fc859b2b12b41c43c45ed2914a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:57:41 +0000 Subject: [PATCH 2/4] Changes before error encountered Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/schema/validate.ts | 217 ++++++++++++++++++++++++++++++- src/type/classes/AbstractType.ts | 5 +- src/type/classes/AnyType.ts | 5 - src/type/classes/ArrayType.ts | 9 -- src/type/classes/BinaryType.ts | 23 ---- 5 files changed, 220 insertions(+), 39 deletions(-) diff --git a/src/schema/validate.ts b/src/schema/validate.ts index 459006fb..e54000ec 100644 --- a/src/schema/validate.ts +++ b/src/schema/validate.ts @@ -1,5 +1,5 @@ import type {Display} from './common'; -import type {TExample, TType, WithValidator} from './schema'; +import type {TExample, TType, WithValidator, Schema} from './schema'; export const validateDisplay = ({title, description, intro}: Display): void => { if (title !== undefined && typeof title !== 'string') throw new Error('INVALID_TITLE'); @@ -44,3 +44,218 @@ export const validateMinMax = (min: number | undefined, max: number | undefined) } if (min !== undefined && max !== undefined && min > max) throw new Error('MIN_MAX'); }; + +// Individual schema validation functions for each node type + +const validateAnySchema = (schema: any): void => { + validateTType(schema, 'any'); +}; + +const validateBooleanSchema = (schema: any): void => { + validateTType(schema, 'bool'); +}; + +const validateNumberSchema = (schema: any): void => { + validateTType(schema, 'num'); + validateWithValidator(schema); + const {format, gt, gte, lt, lte} = schema; + + if (gt !== undefined && typeof gt !== 'number') throw new Error('GT_TYPE'); + if (gte !== undefined && typeof gte !== 'number') throw new Error('GTE_TYPE'); + if (lt !== undefined && typeof lt !== 'number') throw new Error('LT_TYPE'); + if (lte !== undefined && typeof lte !== 'number') throw new Error('LTE_TYPE'); + if (gt !== undefined && gte !== undefined) throw new Error('GT_GTE'); + if (lt !== undefined && lte !== undefined) throw new Error('LT_LTE'); + if ((gt !== undefined || gte !== undefined) && (lt !== undefined || lte !== undefined)) + if ((gt ?? gte)! > (lt ?? lte)!) throw new Error('GT_LT'); + + if (format !== undefined) { + if (typeof format !== 'string') throw new Error('FORMAT_TYPE'); + if (!format) throw new Error('FORMAT_EMPTY'); + switch (format) { + case 'i': + case 'u': + case 'f': + case 'i8': + case 'i16': + case 'i32': + case 'i64': + case 'u8': + case 'u16': + case 'u32': + case 'u64': + case 'f32': + case 'f64': + break; + default: + throw new Error('FORMAT_INVALID'); + } + } +}; + +const validateStringSchema = (schema: any): void => { + validateTType(schema, 'str'); + validateWithValidator(schema); + const {min, max, ascii, noJsonEscape, format} = schema; + + validateMinMax(min, max); + + if (ascii !== undefined) { + if (typeof ascii !== 'boolean') throw new Error('ASCII'); + } + if (noJsonEscape !== undefined) { + if (typeof noJsonEscape !== 'boolean') throw new Error('NO_JSON_ESCAPE_TYPE'); + } + if (format !== undefined) { + if (format !== 'ascii' && format !== 'utf8') { + throw new Error('INVALID_STRING_FORMAT'); + } + // If both format and ascii are specified, they should be consistent + if (ascii !== undefined && format === 'ascii' && !ascii) { + throw new Error('FORMAT_ASCII_MISMATCH'); + } + } +}; + +const binaryFormats = new Set([ + 'bencode', + 'bson', + 'cbor', + 'ion', + 'json', + 'msgpack', + 'resp3', + 'ubjson', +]); + +const validateBinarySchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'bin'); + const {min, max, format} = schema; + validateMinMax(min, max); + if (format !== undefined) { + if (!binaryFormats.has(format)) throw new Error('FORMAT'); + } + validateChildSchema(schema.type); +}; + +const validateArraySchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'arr'); + const {min, max} = schema; + validateMinMax(min, max); + validateChildSchema(schema.type); +}; + +const validateConstSchema = (schema: any): void => { + validateTType(schema, 'const'); +}; + +const validateTupleSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'tup'); + validateWithValidator(schema); + const {types} = schema; + if (!Array.isArray(types)) throw new Error('TYPES_TYPE'); + for (const type of types) validateChildSchema(type); +}; + +const validateObjectSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'obj'); + validateWithValidator(schema); + const {fields, unknownFields} = schema; + if (!Array.isArray(fields)) throw new Error('FIELDS_TYPE'); + if (unknownFields !== undefined && typeof unknownFields !== 'boolean') throw new Error('UNKNOWN_FIELDS_TYPE'); + for (const field of fields) validateChildSchema(field); +}; + +const validateFieldSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'field'); + validateChildSchema(schema.type); +}; + +const validateMapSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'map'); + validateChildSchema(schema.type); +}; + +const validateRefSchema = (schema: any): void => { + validateTType(schema, 'ref'); + const {ref} = schema; + if (typeof ref !== 'string') throw new Error('REF_TYPE'); + if (!ref) throw new Error('REF_EMPTY'); +}; + +const validateOrSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'or'); + const {types, discriminator} = schema; + if (!discriminator || (discriminator[0] === 'num' && discriminator[1] === -1)) throw new Error('DISCRIMINATOR'); + if (!Array.isArray(types)) throw new Error('TYPES_TYPE'); + if (!types.length) throw new Error('TYPES_LENGTH'); + for (const type of types) validateChildSchema(type); +}; + +const validateFunctionSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'fn'); + validateChildSchema(schema.req); + validateChildSchema(schema.res); +}; + +const validateFunctionStreamingSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { + validateTType(schema, 'fn$'); + validateChildSchema(schema.req); + validateChildSchema(schema.res); +}; + +/** + * Main router function that validates a schema based on its kind. + * This replaces the individual validateSchema() methods from type classes. + */ +export const validateSchema = (schema: Schema): void => { + switch (schema.kind) { + case 'any': + validateAnySchema(schema); + break; + case 'bool': + validateBooleanSchema(schema); + break; + case 'num': + validateNumberSchema(schema); + break; + case 'str': + validateStringSchema(schema); + break; + case 'bin': + validateBinarySchema(schema, validateSchema); + break; + case 'arr': + validateArraySchema(schema, validateSchema); + break; + case 'const': + validateConstSchema(schema); + break; + case 'tup': + validateTupleSchema(schema, validateSchema); + break; + case 'obj': + validateObjectSchema(schema, validateSchema); + break; + case 'field': + validateFieldSchema(schema, validateSchema); + break; + case 'map': + validateMapSchema(schema, validateSchema); + break; + case 'ref': + validateRefSchema(schema); + break; + case 'or': + validateOrSchema(schema, validateSchema); + break; + case 'fn': + validateFunctionSchema(schema, validateSchema); + break; + case 'fn$': + validateFunctionStreamingSchema(schema, validateSchema); + break; + default: + throw new Error(`Unknown schema kind: ${(schema as any).kind}`); + } +}; diff --git a/src/type/classes/AbstractType.ts b/src/type/classes/AbstractType.ts index 0d505eb4..e77ec7de 100644 --- a/src/type/classes/AbstractType.ts +++ b/src/type/classes/AbstractType.ts @@ -99,7 +99,10 @@ export abstract class AbstractType implements BaseType< } /** Validates own schema, throws on errors. */ - public abstract validateSchema(): void; + public validateSchema(): void { + const {validateSchema} = require('../../schema/validate'); + validateSchema(this.getSchema()); + } public validate(value: unknown): void { const validator = this.validator('string'); diff --git a/src/type/classes/AnyType.ts b/src/type/classes/AnyType.ts index 9d4c7c70..94de12a0 100644 --- a/src/type/classes/AnyType.ts +++ b/src/type/classes/AnyType.ts @@ -1,6 +1,5 @@ import type * as schema from '../../schema'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; -import {validateTType} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; @@ -30,10 +29,6 @@ export class AnyType extends AbstractType { }; } - public validateSchema(): void { - validateTType(this.getSchema(), 'any'); - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { ctx.emitCustomValidators(this, path, r); } diff --git a/src/type/classes/ArrayType.ts b/src/type/classes/ArrayType.ts index 2e2e8f54..4a5fb672 100644 --- a/src/type/classes/ArrayType.ts +++ b/src/type/classes/ArrayType.ts @@ -2,7 +2,6 @@ import {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; import {printTree} from 'tree-dump/lib/printTree'; import * as schema from '../../schema'; -import {validateMinMax, validateTType} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -59,14 +58,6 @@ export class ArrayType extends AbstractType([ - 'bencode', - 'bson', - 'cbor', - 'ion', - 'json', - 'msgpack', - 'resp3', - 'ubjson', -]); - export class BinaryType extends AbstractType { protected schema: schema.BinarySchema; @@ -64,17 +52,6 @@ export class BinaryType extends AbstractType Date: Sat, 26 Jul 2025 09:12:35 +0000 Subject: [PATCH 3/4] Complete refactoring of validateSchema() methods to centralized system Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/schema/validate.ts | 7 ++++-- src/type/classes/BinaryType.ts | 11 +++++++++ src/type/classes/BooleanType.ts | 4 ---- src/type/classes/ConstType.ts | 5 ----- src/type/classes/FunctionType.ts | 15 ------------- src/type/classes/MapType.ts | 7 ------ src/type/classes/NumberType.ts | 38 -------------------------------- src/type/classes/ObjectType.ts | 20 ----------------- src/type/classes/OrType.ts | 11 --------- src/type/classes/RefType.ts | 9 -------- src/type/classes/StringType.ts | 24 -------------------- src/type/classes/TupleType.ts | 10 --------- 12 files changed, 16 insertions(+), 145 deletions(-) diff --git a/src/schema/validate.ts b/src/schema/validate.ts index e54000ec..6c1661d4 100644 --- a/src/schema/validate.ts +++ b/src/schema/validate.ts @@ -45,7 +45,7 @@ export const validateMinMax = (min: number | undefined, max: number | undefined) if (min !== undefined && max !== undefined && min > max) throw new Error('MIN_MAX'); }; -// Individual schema validation functions for each node type +// Individual schema validation functions for each type const validateAnySchema = (schema: any): void => { validateTType(schema, 'any'); @@ -119,7 +119,7 @@ const validateStringSchema = (schema: any): void => { const binaryFormats = new Set([ 'bencode', - 'bson', + 'bson', 'cbor', 'ion', 'json', @@ -168,6 +168,9 @@ const validateObjectSchema = (schema: any, validateChildSchema: (schema: Schema) const validateFieldSchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { validateTType(schema, 'field'); + const {key, optional} = schema; + if (typeof key !== 'string') throw new Error('KEY_TYPE'); + if (optional !== undefined && typeof optional !== 'boolean') throw new Error('OPTIONAL_TYPE'); validateChildSchema(schema.type); }; diff --git a/src/type/classes/BinaryType.ts b/src/type/classes/BinaryType.ts index 5401c3a0..f38b7e05 100644 --- a/src/type/classes/BinaryType.ts +++ b/src/type/classes/BinaryType.ts @@ -22,6 +22,17 @@ import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; import type * as ts from '../../typescript/types'; import type {TypeExportContext} from '../../system/TypeExportContext'; +const formats = new Set([ + 'bencode', + 'bson', + 'cbor', + 'ion', + 'json', + 'msgpack', + 'resp3', + 'ubjson', +]); + export class BinaryType extends AbstractType { protected schema: schema.BinarySchema; diff --git a/src/type/classes/BooleanType.ts b/src/type/classes/BooleanType.ts index a6136fe1..86c39fb5 100644 --- a/src/type/classes/BooleanType.ts +++ b/src/type/classes/BooleanType.ts @@ -33,10 +33,6 @@ export class BooleanType extends AbstractType { }; } - public validateSchema(): void { - validateTType(this.getSchema(), 'bool'); - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const err = ctx.err(ValidationError.BOOL, path); ctx.js(/* js */ `if(typeof ${r} !== "boolean") return ${err};`); diff --git a/src/type/classes/ConstType.ts b/src/type/classes/ConstType.ts index 3d3715ce..43163baa 100644 --- a/src/type/classes/ConstType.ts +++ b/src/type/classes/ConstType.ts @@ -1,5 +1,4 @@ import {cloneBinary} from '@jsonjoy.com/util/lib/json-clone'; -import {validateTType} from '../../schema/validate'; import {ValidationError} from '../../constants'; import {maxEncodingCapacity} from '@jsonjoy.com/util/lib/json-size'; import {AbstractType} from './AbstractType'; @@ -48,10 +47,6 @@ export class ConstType extends AbstractType> { return options as any; } - public validateSchema(): void { - validateTType(this.getSchema(), 'const'); - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const value = this.schema.value; const equals = deepEqualCodegen(value); diff --git a/src/type/classes/FunctionType.ts b/src/type/classes/FunctionType.ts index 98a3a8a9..3615b7d7 100644 --- a/src/type/classes/FunctionType.ts +++ b/src/type/classes/FunctionType.ts @@ -1,6 +1,5 @@ import {printTree} from 'tree-dump/lib/printTree'; import * as schema from '../../schema'; -import {validateTType} from '../../schema/validate'; import {AbstractType} from './AbstractType'; import type {SchemaOf, Type} from '../types'; import type * as ts from '../../typescript/types'; @@ -50,13 +49,6 @@ export class FunctionType extends AbstractTy }; } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'fn'); - this.req.validateSchema(); - this.res.validateSchema(); - } - public random(): unknown { return async () => this.res.random(); } @@ -129,13 +121,6 @@ export class FunctionStreamingType extends A }; } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'fn$'); - this.req.validateSchema(); - this.res.validateSchema(); - } - public random(): unknown { return async () => this.res.random(); } diff --git a/src/type/classes/MapType.ts b/src/type/classes/MapType.ts index e1aa19b2..de4eda3e 100644 --- a/src/type/classes/MapType.ts +++ b/src/type/classes/MapType.ts @@ -4,7 +4,6 @@ import {asString} from '@jsonjoy.com/util/lib/strings/asString'; import {printTree} from 'tree-dump/lib/printTree'; import * as schema from '../../schema'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; -import {validateTType} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -56,12 +55,6 @@ export class MapType extends AbstractType { return jsonSchema; } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'num'); - validateWithValidator(schema); - const {format, gt, gte, lt, lte} = schema; - if (gt !== undefined && typeof gt !== 'number') throw new Error('GT_TYPE'); - if (gte !== undefined && typeof gte !== 'number') throw new Error('GTE_TYPE'); - if (lt !== undefined && typeof lt !== 'number') throw new Error('LT_TYPE'); - if (lte !== undefined && typeof lte !== 'number') throw new Error('LTE_TYPE'); - if (gt !== undefined && gte !== undefined) throw new Error('GT_GTE'); - if (lt !== undefined && lte !== undefined) throw new Error('LT_LTE'); - if ((gt !== undefined || gte !== undefined) && (lt !== undefined || lte !== undefined)) - if ((gt ?? gte)! > (lt ?? lte)!) throw new Error('GT_LT'); - if (format !== undefined) { - if (typeof format !== 'string') throw new Error('FORMAT_TYPE'); - if (!format) throw new Error('FORMAT_EMPTY'); - switch (format) { - case 'i': - case 'u': - case 'f': - case 'i8': - case 'i16': - case 'i32': - case 'i64': - case 'u8': - case 'u16': - case 'u32': - case 'u64': - case 'f32': - case 'f64': - break; - default: - throw new Error('FORMAT_INVALID'); - } - } - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const {format, gt, gte, lt, lte} = this.schema; if (format && ints.has(format)) { diff --git a/src/type/classes/ObjectType.ts b/src/type/classes/ObjectType.ts index 9c97cd65..2fc9bc43 100644 --- a/src/type/classes/ObjectType.ts +++ b/src/type/classes/ObjectType.ts @@ -5,7 +5,6 @@ import {asString} from '@jsonjoy.com/util/lib/strings/asString'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; import {printTree} from 'tree-dump/lib/printTree'; import * as schema from '../../schema'; -import {validateTType, validateWithValidator} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -62,15 +61,6 @@ export class ObjectFieldType extends AbstractT return options as any; } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'field'); - const {key, optional} = schema; - if (typeof key !== 'string') throw new Error('KEY_TYPE'); - if (optional !== undefined && typeof optional !== 'boolean') throw new Error('OPTIONAL_TYPE'); - this.value.validateSchema(); - } - protected toStringTitle(): string { return `"${this.key}":`; } @@ -163,16 +153,6 @@ export class ObjectType[] = ObjectFieldType< return type; } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'obj'); - validateWithValidator(schema); - const {fields, unknownFields} = schema; - if (!Array.isArray(fields)) throw new Error('FIELDS_TYPE'); - if (unknownFields !== undefined && typeof unknownFields !== 'boolean') throw new Error('UNKNOWN_FIELDS_TYPE'); - for (const field of this.fields) field.validateSchema(); - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const fields = this.fields; const length = fields.length; diff --git a/src/type/classes/OrType.ts b/src/type/classes/OrType.ts index 369f44a0..c726584e 100644 --- a/src/type/classes/OrType.ts +++ b/src/type/classes/OrType.ts @@ -1,6 +1,5 @@ import * as schema from '../../schema'; import {printTree} from 'tree-dump/lib/printTree'; -import {validateTType} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -75,16 +74,6 @@ export class OrType extends AbstractType +(fn(new Vars(data)) as any)); } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'or'); - const {types, discriminator} = schema; - if (!discriminator || (discriminator[0] === 'num' && discriminator[1] === -1)) throw new Error('DISCRIMINATOR'); - if (!Array.isArray(types)) throw new Error('TYPES_TYPE'); - if (!types.length) throw new Error('TYPES_LENGTH'); - for (const type of this.types) type.validateSchema(); - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const types = this.types; const codegen = ctx.codegen; diff --git a/src/type/classes/RefType.ts b/src/type/classes/RefType.ts index 06793538..9aa0bfb2 100644 --- a/src/type/classes/RefType.ts +++ b/src/type/classes/RefType.ts @@ -1,5 +1,4 @@ import * as schema from '../../schema'; -import {validateTType} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -48,14 +47,6 @@ export class RefType extends AbstractType { switch (ctx.options.errors) { diff --git a/src/type/classes/StringType.ts b/src/type/classes/StringType.ts index 2931d036..8d5d984b 100644 --- a/src/type/classes/StringType.ts +++ b/src/type/classes/StringType.ts @@ -1,7 +1,6 @@ import type * as schema from '../../schema'; import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; import {asString} from '@jsonjoy.com/util/lib/strings/asString'; -import {validateMinMax, validateTType, validateWithValidator} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -51,29 +50,6 @@ export class StringType extends AbstractType { return jsonSchema; } - public validateSchema(): void { - const schema = this.getSchema(); - validateTType(schema, 'str'); - validateWithValidator(schema); - const {min, max, ascii, noJsonEscape, format} = schema; - validateMinMax(min, max); - if (ascii !== undefined) { - if (typeof ascii !== 'boolean') throw new Error('ASCII'); - } - if (noJsonEscape !== undefined) { - if (typeof noJsonEscape !== 'boolean') throw new Error('NO_JSON_ESCAPE_TYPE'); - } - if (format !== undefined) { - if (format !== 'ascii' && format !== 'utf8') { - throw new Error('INVALID_STRING_FORMAT'); - } - // If both format and ascii are specified, they should be consistent - if (ascii !== undefined && format === 'ascii' && !ascii) { - throw new Error('FORMAT_ASCII_MISMATCH'); - } - } - } - public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const error = ctx.err(ValidationError.STR, path); ctx.js(/* js */ `if(typeof ${r} !== "string") return ${error};`); diff --git a/src/type/classes/TupleType.ts b/src/type/classes/TupleType.ts index a5e4c09c..0760f4cf 100644 --- a/src/type/classes/TupleType.ts +++ b/src/type/classes/TupleType.ts @@ -1,6 +1,5 @@ import * as schema from '../../schema'; import {printTree} from 'tree-dump/lib/printTree'; -import {validateTType} from '../../schema/validate'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; @@ -52,15 +51,6 @@ export class TupleType extends AbstractType Date: Sat, 26 Jul 2025 16:37:13 +0000 Subject: [PATCH 4/4] Add comprehensive schema validation tests Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/schema/__tests__/validate.spec.ts | 564 ++++++++++++++++++++++++++ src/schema/validate.ts | 19 +- 2 files changed, 569 insertions(+), 14 deletions(-) create mode 100644 src/schema/__tests__/validate.spec.ts diff --git a/src/schema/__tests__/validate.spec.ts b/src/schema/__tests__/validate.spec.ts new file mode 100644 index 00000000..265d63e7 --- /dev/null +++ b/src/schema/__tests__/validate.spec.ts @@ -0,0 +1,564 @@ +import { + validateDisplay, + validateTExample, + validateTType, + validateWithValidator, + validateMinMax, + validateSchema, +} from '../validate'; +import type {TExample, TType, WithValidator, Schema} from '../schema'; +import type {Display} from '../common'; + +describe('validateDisplay', () => { + test('validates valid display', () => { + expect(() => validateDisplay({})).not.toThrow(); + expect(() => validateDisplay({title: 'Test'})).not.toThrow(); + expect(() => validateDisplay({description: 'Test description'})).not.toThrow(); + expect(() => validateDisplay({intro: 'Test intro'})).not.toThrow(); + expect(() => + validateDisplay({ + title: 'Test', + description: 'Test description', + intro: 'Test intro', + }), + ).not.toThrow(); + }); + + test('throws for invalid title', () => { + expect(() => validateDisplay({title: 123} as any)).toThrow('INVALID_TITLE'); + expect(() => validateDisplay({title: null} as any)).toThrow('INVALID_TITLE'); + expect(() => validateDisplay({title: {}} as any)).toThrow('INVALID_TITLE'); + }); + + test('throws for invalid description', () => { + expect(() => validateDisplay({description: 123} as any)).toThrow('INVALID_DESCRIPTION'); + expect(() => validateDisplay({description: null} as any)).toThrow('INVALID_DESCRIPTION'); + expect(() => validateDisplay({description: []} as any)).toThrow('INVALID_DESCRIPTION'); + }); + + test('throws for invalid intro', () => { + expect(() => validateDisplay({intro: 123} as any)).toThrow('INVALID_INTRO'); + expect(() => validateDisplay({intro: null} as any)).toThrow('INVALID_INTRO'); + expect(() => validateDisplay({intro: false} as any)).toThrow('INVALID_INTRO'); + }); +}); + +describe('validateTExample', () => { + test('validates valid example', () => { + const example: TExample = {value: 'test'}; + expect(() => validateTExample(example)).not.toThrow(); + }); + + test('validates example with display properties', () => { + const example: TExample = { + value: 'test', + title: 'Example', + description: 'Test example', + }; + expect(() => validateTExample(example)).not.toThrow(); + }); + + test('throws for invalid display properties', () => { + expect(() => validateTExample({title: 123} as any)).toThrow('INVALID_TITLE'); + }); +}); + +describe('validateTType', () => { + test('validates valid TType', () => { + const ttype: TType = {kind: 'str'}; + expect(() => validateTType(ttype, 'str')).not.toThrow(); + }); + + test('validates TType with id', () => { + const ttype: TType = {kind: 'str', id: 'test-id'}; + expect(() => validateTType(ttype, 'str')).not.toThrow(); + }); + + test('validates TType with examples', () => { + const ttype: TType = { + kind: 'str', + examples: [ + {value: 'test1', title: 'Example 1'}, + {value: 'test2', description: 'Example 2'}, + ], + }; + expect(() => validateTType(ttype, 'str')).not.toThrow(); + }); + + test('throws for invalid kind', () => { + const ttype: TType = {kind: 'str'}; + expect(() => validateTType(ttype, 'num')).toThrow('INVALID_TYPE'); + }); + + test('throws for invalid id', () => { + expect(() => validateTType({kind: 'str', id: 123} as any, 'str')).toThrow('INVALID_ID'); + expect(() => validateTType({kind: 'str', id: null} as any, 'str')).toThrow('INVALID_ID'); + }); + + test('throws for invalid examples', () => { + expect(() => validateTType({kind: 'str', examples: 'not-array'} as any, 'str')).toThrow('INVALID_EXAMPLES'); + expect(() => validateTType({kind: 'str', examples: [{value: 'test', title: 123}]} as any, 'str')).toThrow( + 'INVALID_TITLE', + ); + }); + + test('validates display properties', () => { + expect(() => validateTType({kind: 'str', title: 123} as any, 'str')).toThrow('INVALID_TITLE'); + }); +}); + +describe('validateWithValidator', () => { + test('validates empty validator', () => { + expect(() => validateWithValidator({})).not.toThrow(); + }); + + test('validates string validator', () => { + const withValidator: WithValidator = {validator: 'test-validator'}; + expect(() => validateWithValidator(withValidator)).not.toThrow(); + }); + + test('validates array validator', () => { + const withValidator: WithValidator = {validator: ['validator1', 'validator2']}; + expect(() => validateWithValidator(withValidator)).not.toThrow(); + }); + + test('throws for non-string validator', () => { + expect(() => validateWithValidator({validator: 123} as any)).toThrow('INVALID_VALIDATOR'); + expect(() => validateWithValidator({validator: null} as any)).toThrow('INVALID_VALIDATOR'); + expect(() => validateWithValidator({validator: {}} as any)).toThrow('INVALID_VALIDATOR'); + }); + + test('throws for array with non-string elements', () => { + expect(() => validateWithValidator({validator: ['valid', 123]} as any)).toThrow('INVALID_VALIDATOR'); + expect(() => validateWithValidator({validator: [null, 'valid']} as any)).toThrow('INVALID_VALIDATOR'); + }); +}); + +describe('validateMinMax', () => { + test('validates empty min/max', () => { + expect(() => validateMinMax(undefined, undefined)).not.toThrow(); + }); + + test('validates valid min/max', () => { + expect(() => validateMinMax(0, 10)).not.toThrow(); + expect(() => validateMinMax(5, undefined)).not.toThrow(); + expect(() => validateMinMax(undefined, 15)).not.toThrow(); + }); + + test('throws for invalid min type', () => { + expect(() => validateMinMax('5' as any, undefined)).toThrow('MIN_TYPE'); + expect(() => validateMinMax(null as any, undefined)).toThrow('MIN_TYPE'); + }); + + test('throws for invalid max type', () => { + expect(() => validateMinMax(undefined, '10' as any)).toThrow('MAX_TYPE'); + expect(() => validateMinMax(undefined, {} as any)).toThrow('MAX_TYPE'); + }); + + test('throws for negative min', () => { + expect(() => validateMinMax(-1, undefined)).toThrow('MIN_NEGATIVE'); + expect(() => validateMinMax(-10, undefined)).toThrow('MIN_NEGATIVE'); + }); + + test('throws for negative max', () => { + expect(() => validateMinMax(undefined, -1)).toThrow('MAX_NEGATIVE'); + expect(() => validateMinMax(undefined, -5)).toThrow('MAX_NEGATIVE'); + }); + + test('throws for decimal min', () => { + expect(() => validateMinMax(1.5, undefined)).toThrow('MIN_DECIMAL'); + expect(() => validateMinMax(0.1, undefined)).toThrow('MIN_DECIMAL'); + }); + + test('throws for decimal max', () => { + expect(() => validateMinMax(undefined, 1.5)).toThrow('MAX_DECIMAL'); + expect(() => validateMinMax(undefined, 10.9)).toThrow('MAX_DECIMAL'); + }); + + test('throws when min > max', () => { + expect(() => validateMinMax(10, 5)).toThrow('MIN_MAX'); + expect(() => validateMinMax(1, 0)).toThrow('MIN_MAX'); + }); + + test('allows min = max', () => { + expect(() => validateMinMax(5, 5)).not.toThrow(); + expect(() => validateMinMax(0, 0)).not.toThrow(); + }); +}); + +describe('validateSchema', () => { + describe('any schema', () => { + test('validates valid any schema', () => { + const schema: Schema = {kind: 'any'}; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates any schema with metadata', () => { + const schema: Schema = { + kind: 'any', + metadata: {custom: 'value'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('boolean schema', () => { + test('validates valid boolean schema', () => { + const schema: Schema = {kind: 'bool'}; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('number schema', () => { + test('validates valid number schema', () => { + const schema: Schema = {kind: 'num'}; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates number schema with constraints', () => { + const schema: Schema = { + kind: 'num', + gt: 0, + lt: 100, + format: 'i32', + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates number schema with gte/lte', () => { + const schema: Schema = { + kind: 'num', + gte: 0, + lte: 100, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('throws for invalid constraint types', () => { + expect(() => validateSchema({kind: 'num', gt: '5'} as any)).toThrow('GT_TYPE'); + expect(() => validateSchema({kind: 'num', gte: null} as any)).toThrow('GTE_TYPE'); + expect(() => validateSchema({kind: 'num', lt: {}} as any)).toThrow('LT_TYPE'); + expect(() => validateSchema({kind: 'num', lte: []} as any)).toThrow('LTE_TYPE'); + }); + + test('throws for conflicting constraints', () => { + expect(() => validateSchema({kind: 'num', gt: 5, gte: 3} as any)).toThrow('GT_GTE'); + expect(() => validateSchema({kind: 'num', lt: 10, lte: 15} as any)).toThrow('LT_LTE'); + }); + + test('throws for invalid range', () => { + expect(() => validateSchema({kind: 'num', gt: 10, lt: 5} as any)).toThrow('GT_LT'); + expect(() => validateSchema({kind: 'num', gte: 10, lte: 5} as any)).toThrow('GT_LT'); + }); + + test('validates all number formats', () => { + const formats = ['i', 'u', 'f', 'i8', 'i16', 'i32', 'i64', 'u8', 'u16', 'u32', 'u64', 'f32', 'f64'] as const; + for (const format of formats) { + expect(() => validateSchema({kind: 'num', format})).not.toThrow(); + } + }); + + test('throws for invalid format', () => { + expect(() => validateSchema({kind: 'num', format: 'invalid'} as any)).toThrow('FORMAT_INVALID'); + expect(() => validateSchema({kind: 'num', format: ''} as any)).toThrow('FORMAT_EMPTY'); + expect(() => validateSchema({kind: 'num', format: 123} as any)).toThrow('FORMAT_TYPE'); + }); + }); + + describe('string schema', () => { + test('validates valid string schema', () => { + const schema: Schema = {kind: 'str'}; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates string schema with constraints', () => { + const schema: Schema = { + kind: 'str', + min: 1, + max: 100, + format: 'ascii', + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates string formats', () => { + expect(() => validateSchema({kind: 'str', format: 'ascii'})).not.toThrow(); + expect(() => validateSchema({kind: 'str', format: 'utf8'})).not.toThrow(); + }); + + test('throws for invalid string format', () => { + expect(() => validateSchema({kind: 'str', format: 'invalid'} as any)).toThrow('INVALID_STRING_FORMAT'); + }); + + test('validates ascii property', () => { + expect(() => validateSchema({kind: 'str', ascii: true})).not.toThrow(); + expect(() => validateSchema({kind: 'str', ascii: false})).not.toThrow(); + }); + + test('throws for invalid ascii type', () => { + expect(() => validateSchema({kind: 'str', ascii: 'true'} as any)).toThrow('ASCII'); + }); + + test('validates noJsonEscape property', () => { + expect(() => validateSchema({kind: 'str', noJsonEscape: true})).not.toThrow(); + expect(() => validateSchema({kind: 'str', noJsonEscape: false})).not.toThrow(); + }); + + test('throws for invalid noJsonEscape type', () => { + expect(() => validateSchema({kind: 'str', noJsonEscape: 'true'} as any)).toThrow('NO_JSON_ESCAPE_TYPE'); + }); + + test('throws for format/ascii mismatch', () => { + expect(() => validateSchema({kind: 'str', format: 'ascii', ascii: false} as any)).toThrow( + 'FORMAT_ASCII_MISMATCH', + ); + }); + }); + + describe('binary schema', () => { + test('validates valid binary schema', () => { + const schema: Schema = { + kind: 'bin', + type: {kind: 'str'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates binary schema with format', () => { + const formats = ['json', 'cbor', 'msgpack', 'resp3', 'ion', 'bson', 'ubjson', 'bencode'] as const; + for (const format of formats) { + const schema: Schema = { + kind: 'bin', + type: {kind: 'str'}, + format, + }; + expect(() => validateSchema(schema)).not.toThrow(); + } + }); + + test('throws for invalid format', () => { + expect(() => + validateSchema({ + kind: 'bin', + type: {kind: 'str'}, + format: 'invalid', + } as any), + ).toThrow('FORMAT'); + }); + }); + + describe('array schema', () => { + test('validates valid array schema', () => { + const schema: Schema = { + kind: 'arr', + type: {kind: 'str'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates array schema with constraints', () => { + const schema: Schema = { + kind: 'arr', + type: {kind: 'num'}, + min: 1, + max: 10, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('const schema', () => { + test('validates valid const schema', () => { + const schema: Schema = {kind: 'const', value: 'test'}; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('tuple schema', () => { + test('validates valid tuple schema', () => { + const schema: Schema = { + kind: 'tup', + types: [{kind: 'str'}, {kind: 'num'}], + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('throws for invalid types property', () => { + expect(() => validateSchema({kind: 'tup', types: 'not-array'} as any)).toThrow('TYPES_TYPE'); + }); + }); + + describe('object schema', () => { + test('validates valid object schema', () => { + const schema: Schema = { + kind: 'obj', + fields: [ + { + kind: 'field', + key: 'name', + type: {kind: 'str'}, + }, + ], + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates object schema with unknownFields', () => { + const schema: Schema = { + kind: 'obj', + fields: [], + unknownFields: true, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('throws for invalid fields type', () => { + expect(() => validateSchema({kind: 'obj', fields: 'not-array'} as any)).toThrow('FIELDS_TYPE'); + }); + + test('throws for invalid unknownFields type', () => { + expect(() => validateSchema({kind: 'obj', fields: [], unknownFields: 'true'} as any)).toThrow( + 'UNKNOWN_FIELDS_TYPE', + ); + }); + }); + + describe('field schema', () => { + test('validates valid field schema', () => { + const schema: Schema = { + kind: 'field', + key: 'test', + type: {kind: 'str'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('validates optional field schema', () => { + const schema: Schema = { + kind: 'field', + key: 'test', + type: {kind: 'str'}, + optional: true, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('throws for invalid key type', () => { + expect(() => + validateSchema({ + kind: 'field', + key: 123, + type: {kind: 'str'}, + } as any), + ).toThrow('KEY_TYPE'); + }); + + test('throws for invalid optional type', () => { + expect(() => + validateSchema({ + kind: 'field', + key: 'test', + type: {kind: 'str'}, + optional: 'true', + } as any), + ).toThrow('OPTIONAL_TYPE'); + }); + }); + + describe('map schema', () => { + test('validates valid map schema', () => { + const schema: Schema = { + kind: 'map', + type: {kind: 'str'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('ref schema', () => { + test('validates valid ref schema', () => { + const schema: Schema = { + kind: 'ref', + ref: 'TestType' as any, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('throws for invalid ref type', () => { + expect(() => validateSchema({kind: 'ref', ref: 123} as any)).toThrow('REF_TYPE'); + }); + + test('throws for empty ref', () => { + expect(() => validateSchema({kind: 'ref', ref: ''} as any)).toThrow('REF_EMPTY'); + }); + }); + + describe('or schema', () => { + test('validates valid or schema', () => { + const schema: Schema = { + kind: 'or', + types: [{kind: 'str'}, {kind: 'num'}], + discriminator: ['str', 0], + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + + test('throws for invalid discriminator', () => { + expect(() => + validateSchema({ + kind: 'or', + types: [{kind: 'str'}], + discriminator: null, + } as any), + ).toThrow('DISCRIMINATOR'); + }); + + test('throws for invalid types', () => { + expect(() => + validateSchema({ + kind: 'or', + types: 'not-array', + discriminator: ['str', 0], + } as any), + ).toThrow('TYPES_TYPE'); + }); + + test('throws for empty types', () => { + expect(() => + validateSchema({ + kind: 'or', + types: [], + discriminator: ['str', 0], + } as any), + ).toThrow('TYPES_LENGTH'); + }); + }); + + describe('function schema', () => { + test('validates valid function schema', () => { + const schema: Schema = { + kind: 'fn', + req: {kind: 'str'}, + res: {kind: 'num'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('streaming function schema', () => { + test('validates valid streaming function schema', () => { + const schema: Schema = { + kind: 'fn$', + req: {kind: 'str'}, + res: {kind: 'num'}, + }; + expect(() => validateSchema(schema)).not.toThrow(); + }); + }); + + describe('unknown schema kind', () => { + test('throws for unknown schema kind', () => { + expect(() => validateSchema({kind: 'unknown'} as any)).toThrow('Unknown schema kind: unknown'); + }); + }); +}); diff --git a/src/schema/validate.ts b/src/schema/validate.ts index 6c1661d4..17479bcc 100644 --- a/src/schema/validate.ts +++ b/src/schema/validate.ts @@ -59,7 +59,7 @@ const validateNumberSchema = (schema: any): void => { validateTType(schema, 'num'); validateWithValidator(schema); const {format, gt, gte, lt, lte} = schema; - + if (gt !== undefined && typeof gt !== 'number') throw new Error('GT_TYPE'); if (gte !== undefined && typeof gte !== 'number') throw new Error('GTE_TYPE'); if (lt !== undefined && typeof lt !== 'number') throw new Error('LT_TYPE'); @@ -68,7 +68,7 @@ const validateNumberSchema = (schema: any): void => { if (lt !== undefined && lte !== undefined) throw new Error('LT_LTE'); if ((gt !== undefined || gte !== undefined) && (lt !== undefined || lte !== undefined)) if ((gt ?? gte)! > (lt ?? lte)!) throw new Error('GT_LT'); - + if (format !== undefined) { if (typeof format !== 'string') throw new Error('FORMAT_TYPE'); if (!format) throw new Error('FORMAT_EMPTY'); @@ -97,9 +97,9 @@ const validateStringSchema = (schema: any): void => { validateTType(schema, 'str'); validateWithValidator(schema); const {min, max, ascii, noJsonEscape, format} = schema; - + validateMinMax(min, max); - + if (ascii !== undefined) { if (typeof ascii !== 'boolean') throw new Error('ASCII'); } @@ -117,16 +117,7 @@ const validateStringSchema = (schema: any): void => { } }; -const binaryFormats = new Set([ - 'bencode', - 'bson', - 'cbor', - 'ion', - 'json', - 'msgpack', - 'resp3', - 'ubjson', -]); +const binaryFormats = new Set(['bencode', 'bson', 'cbor', 'ion', 'json', 'msgpack', 'resp3', 'ubjson']); const validateBinarySchema = (schema: any, validateChildSchema: (schema: Schema) => void): void => { validateTType(schema, 'bin');