diff --git a/package.json b/package.json index a725db0..4c3a200 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "@release-it/conventional-changelog": "^10.0.3", "@swc/core": "1.15.3", "@types/dlv": "^1.1.5", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.6", "@types/luxon": "^3.7.1", "@types/node": "^25.0.0", "benchmark": "^2.1.4", diff --git a/src/schema/any/main.ts b/src/schema/any/main.ts index d171409..61271c2 100644 --- a/src/schema/any/main.ts +++ b/src/schema/any/main.ts @@ -10,6 +10,7 @@ import { BaseLiteralType } from '../base/literal.js' import type { FieldOptions, Validation } from '../../types.js' import { SUBTYPE } from '../../symbols.js' +import { type JSONSchema7 } from 'json-schema' /** * VineAny represents a value that can be anything @@ -31,4 +32,26 @@ export class VineAny extends BaseLiteralType { clone(): this { return new VineAny(this.cloneOptions(), this.cloneValidations()) as this } + + /** + * Transforms into JSONSchema. + */ + toJSONSchema(): JSONSchema7 { + const schema: JSONSchema7 = { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + { type: 'object' }, + ], + } + + for (const validation of this.validations) { + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) + } + + return schema + } } diff --git a/src/schema/array/main.ts b/src/schema/array/main.ts index 854b9d1..542aa28 100644 --- a/src/schema/array/main.ts +++ b/src/schema/array/main.ts @@ -19,7 +19,13 @@ import { UNIQUE_NAME, IS_OF_TYPE, } from '../../symbols.js' -import type { FieldOptions, ParserOptions, SchemaTypes, Validation } from '../../types.js' +import type { + FieldOptions, + ParserOptions, + SchemaTypes, + Validation, + WithJSONSchema, +} from '../../types.js' import { compactRule, @@ -29,6 +35,7 @@ import { maxLengthRule, fixedLengthRule, } from './rules.js' +import { type JSONSchema7 } from 'json-schema' /** * VineArray represents an array schema type in the validation pipeline. @@ -48,11 +55,10 @@ import { * data: ['user1@example.com', 'user2@example.com'] * }) */ -export class VineArray extends BaseType< - Schema[typeof ITYPE][], - Schema[typeof OTYPE][], - Schema[typeof COTYPE][] -> { +export class VineArray + extends BaseType + implements WithJSONSchema +{ /** * Static collection of all available validation rules for arrays */ @@ -166,6 +172,23 @@ export class VineArray extends BaseType< return new VineArray(this.#schema.clone(), this.cloneOptions(), this.cloneValidations()) as this } + /** + * Transforms into JSONSchema. + */ + toJSONSchema(): JSONSchema7 { + const schema: JSONSchema7 = { + type: 'array', + items: this.#schema.toJSONSchema?.() ?? {}, + } + + for (const validation of this.validations) { + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) + } + + return schema + } + /** * Compiles to array data type for the validation compiler. * diff --git a/src/schema/array/rules.ts b/src/schema/array/rules.ts index c472c52..e5b2629 100644 --- a/src/schema/array/rules.ts +++ b/src/schema/array/rules.ts @@ -14,26 +14,40 @@ import { createRule } from '../../vine/create_rule.js' /** * Enforce a minimum length on an array field */ -export const minLengthRule = createRule<{ min: number }>(function minLength(value, options, field) { - /** - * Value will always be an array if the field is valid. - */ - if ((value as unknown[]).length < options.min) { - field.report(messages['array.minLength'], 'array.minLength', field, options) +export const minLengthRule = createRule<{ min: number }>( + function minLength(value, options, field) { + /** + * Value will always be an array if the field is valid. + */ + if ((value as unknown[]).length < options.min) { + field.report(messages['array.minLength'], 'array.minLength', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.minItems = options.min + }, } -}) +) /** * Enforce a maximum length on an array field */ -export const maxLengthRule = createRule<{ max: number }>(function maxLength(value, options, field) { - /** - * Value will always be an array if the field is valid. - */ - if ((value as unknown[]).length > options.max) { - field.report(messages['array.maxLength'], 'array.maxLength', field, options) +export const maxLengthRule = createRule<{ max: number }>( + function maxLength(value, options, field) { + /** + * Value will always be an array if the field is valid. + */ + if ((value as unknown[]).length > options.max) { + field.report(messages['array.maxLength'], 'array.maxLength', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.maxItems = options.max + }, } -}) +) /** * Enforce a fixed length on an array field @@ -46,20 +60,33 @@ export const fixedLengthRule = createRule<{ size: number }>( if ((value as unknown[]).length !== options.size) { field.report(messages['array.fixedLength'], 'array.fixedLength', field, options) } + }, + { + toJSONSchema: (schema, options) => { + schema.minItems = options.size + schema.maxItems = options.size + }, } ) /** * Ensure the array is not empty */ -export const notEmptyRule = createRule(function notEmpty(value, _, field) { - /** - * Value will always be an array if the field is valid. - */ - if ((value as unknown[]).length <= 0) { - field.report(messages.notEmpty, 'notEmpty', field) +export const notEmptyRule = createRule( + function notEmpty(value, _, field) { + /** + * Value will always be an array if the field is valid. + */ + if ((value as unknown[]).length <= 0) { + field.report(messages.notEmpty, 'notEmpty', field) + } + }, + { + toJSONSchema: (schema) => { + schema.minItems = 1 + }, } -}) +) /** * Ensure array elements are distinct/unique @@ -72,6 +99,11 @@ export const distinctRule = createRule<{ fields?: string | string[] }>( if (!helpers.isDistinct(value as any[], options.fields)) { field.report(messages.distinct, 'distinct', field, options) } + }, + { + toJSONSchema: (schema) => { + schema.uniqueItems = true + }, } ) diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index 23dac9e..4337e52 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -23,6 +23,7 @@ import type { WithCustomRules, } from '../../types.js' import { ConditionalValidations } from './conditional_rules.js' +import { type JSONSchema7 } from 'json-schema' /** * Modifies the schema type to allow null values @@ -89,6 +90,144 @@ export class NullableModifier< return new TransformModifier(transformer, this) } + /** + * Add meta to the field that can be retrieved once compiled. + * It is also merged with the json-schema. + */ + meta(meta: JSONSchema7 | Object): MetaModifier { + return new MetaModifier(this, meta) + } + + toJSONSchema(): JSONSchema7 { + const schema = this.#parent.toJSONSchema?.() ?? {} + + if (schema.anyOf) { + schema.anyOf.push({ type: 'null' }) + return schema + } + + if (schema.enum) { + return { + anyOf: [schema, { type: 'null' }], + } + } + + if (schema.type === undefined) { + schema.type = 'null' + return schema + } + + if (typeof schema.type === 'string') { + schema.type = [schema.type, 'null'] + return schema + } + + if (Array.isArray(schema.type)) { + schema.type.push('null') + return schema + } + + return schema + } + + /** + * Compiles to compiler node + */ + [PARSE]( + propertyName: string, + refs: RefsStore, + options: ParserOptions + ): LiteralNode & { subtype: string } { + const output = this.#parent[PARSE](propertyName, refs, options) + output.allowNull = true + return output + } +} + +/** + * Adds meta data to the schema type. + */ +export class MetaModifier< + Schema extends ConstructableLiteralSchema, +> implements ConstructableLiteralSchema< + Schema[typeof ITYPE], + Schema[typeof OTYPE], + Schema[typeof COTYPE] +> { + /** + * Define the input type of the schema + */ + declare [ITYPE]: Schema[typeof ITYPE]; + + /** + * The output value of the field. The property points to a type only + * and not the real value. + */ + declare [OTYPE]: Schema[typeof OTYPE]; + declare [COTYPE]: Schema[typeof COTYPE] + + #parent: Schema + #meta: JSONSchema7 + + constructor(parent: Schema, meta: JSONSchema7) { + this.#parent = parent + this.#meta = meta + } + + /** + * Mark the field under validation as optional. An optional + * field allows both null and undefined values. + */ + optional(): OptionalModifier { + return new OptionalModifier(this) + } + + /** + * Apply a transformation to the final validated value. + * The transformer receives the validated value and can convert it to any new datatype. + * + * @template TransformedOutput - The type of the transformed output + * @param transformer - Function to transform the validated value + * @returns A new TransformModifier wrapping this schema + * + * @example + * vine.string().nullable().transform((value) => { + * return value ? value.toUpperCase() : null + * }) + */ + transform( + transformer: Transformer + ): TransformModifier { + return new TransformModifier(transformer, this) + } + + /** + * Mark the field under validation to be null. The null value will + * be written to the output as well. + * + * If `optional` and `nullable` are used together, then both undefined + * and null values will be allowed. + */ + nullable(): NullableModifier { + return new NullableModifier(this) + } + + /** + * Creates a fresh instance of the underlying schema type + * and wraps it inside the meta modifier + */ + clone(): this { + return new MetaModifier(this.#parent.clone(), this.#meta) as this + } + + toJSONSchema(): JSONSchema7 { + const schema = this.#parent.toJSONSchema?.() ?? {} + return { + ...schema, + ...this.#meta, + } + } + /** * Compiles to compiler node */ @@ -229,6 +368,22 @@ export class OptionalModifier { + return new MetaModifier(this, meta) + } + + toJSONSchema(): JSONSchema7 & { isOptional: true } { + return { + ...this.#parent.toJSONSchema(), + // Custom property allowing object schema type to set property as not required. + isOptional: true, + } + } + /** * Compiles to compiler node */ @@ -317,6 +472,18 @@ export class TransformModifier< return new NullableModifier(this) } + /** + * Add meta to the field that can be retrieved once compiled. + * It is also merged with the json-schema. + */ + meta(meta: JSONSchema7 | Object): MetaModifier { + return new MetaModifier(this, meta) + } + + toJSONSchema(): JSONSchema7 { + return this.#parent.toJSONSchema() + } + /** * Compiles to compiler node */ @@ -391,7 +558,7 @@ export abstract class BaseLiteralType /** * Configuration options for this field including bail mode, nullability, and parsing */ - protected options: FieldOptions + options: FieldOptions /** * Array of validation rules to apply to the field value @@ -469,6 +636,21 @@ export abstract class BaseLiteralType return this.validations.map((validation) => this.compileValidation(validation, refs)) } + toJSONSchema(): JSONSchema7 { + const schema: JSONSchema7 = {} + + if (this.dataTypeValidator?.rule.toJSONSchema) { + this.dataTypeValidator.rule.toJSONSchema(schema, this.dataTypeValidator.options) + } + + for (const validation of this.validations) { + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) + } + + return schema + } + /** * Define a method to parse the input value. The method * is invoked before any validation and hence you must @@ -531,6 +713,14 @@ export abstract class BaseLiteralType return new NullableModifier(this) } + /** + * Add meta to the field that can be retrieved once compiled. + * It is also merged with the json-schema. + */ + meta(meta: JSONSchema7 | Object): MetaModifier { + return new MetaModifier(this, meta) + } + /** * Apply transform on the final validated value. The transform method may * convert the value to any new datatype. diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index e532c89..f46a424 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -22,6 +22,7 @@ import type { WithCustomRules, } from '../../types.js' import { ConditionalValidations } from './conditional_rules.js' +import { type JSONSchema7 } from 'json-schema' /** * Modifies the schema type to allow null values in addition to the @@ -78,6 +79,14 @@ export class NullableModifier< return new OptionalModifier(this) } + /** + * Add meta to the field that can be retrieved once compiled. + * It is also merged with the json-schema. + */ + meta(meta: JSONSchema7 | Object): MetaModifier { + return new MetaModifier(this, meta) + } + /** * Creates a fresh instance of the underlying schema type * and wraps it inside the nullable modifier. @@ -88,6 +97,36 @@ export class NullableModifier< return new NullableModifier(this.#parent.clone()) as this } + toJSONSchema(): JSONSchema7 { + const schema = this.#parent.toJSONSchema?.() + + if (!schema) { + return { type: 'null' } + } + + if (schema.anyOf) { + schema.anyOf.push({ type: 'null' }) + return schema + } + + if (schema.type === undefined) { + schema.type = 'null' + return schema + } + + if (typeof schema.type === 'string') { + schema.type = [schema.type, 'null'] + return schema + } + + if (Array.isArray(schema.type)) { + schema.type.push('null') + return schema + } + + return schema + } + /** * Compiles to compiler node by delegating to the parent schema * and setting the allowNull flag. @@ -107,6 +146,63 @@ export class NullableModifier< } } +export class MetaModifier< + Schema extends ConstructableSchema, +> implements ConstructableSchema< + Schema[typeof ITYPE], + Schema[typeof OTYPE], + Schema[typeof COTYPE] +> { + /** + * Define the input type of the schema + */ + declare [ITYPE]: Schema[typeof ITYPE]; + + /** + * The output value of the field. The property points to a type only + * and not the real value. + */ + declare [OTYPE]: Schema[typeof OTYPE]; + declare [COTYPE]: Schema[typeof COTYPE] + + #parent: Schema + #meta: JSONSchema7 | Object + + constructor(parent: Schema, meta: JSONSchema7 | Object) { + this.#parent = parent + this.#meta = meta + } + + /** + * Creates a fresh instance of the underlying schema type + * and wraps it inside the meta modifier + */ + clone(): this { + return new MetaModifier(this.#parent.clone(), this.#meta) as this + } + + toJSONSchema(): JSONSchema7 { + const parent = this.#parent.toJSONSchema?.() ?? {} + return { + ...parent, + ...this.#meta, + } + } + + /** + * Compiles to compiler node by delegating to the parent schema + * and setting the allowNull flag. + * + * @param propertyName - Name of the property being compiled + * @param refs - Reference store for the compiler + * @param options - Parser options + * @returns Compiled compiler node with null support + */ + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): CompilerNodes { + return this.#parent[PARSE](propertyName, refs, options) + } +} + /** * Modifies the schema type to allow undefined values in addition to the * original schema type. This is useful for form fields that may not be @@ -209,6 +305,14 @@ export class OptionalModifier> return new NullableModifier(this) } + /** + * Add meta to the field that can be retrieved once compiled. + * It is also merged with the json-schema. + */ + meta(meta: JSONSchema7 | Object): MetaModifier { + return new MetaModifier(this, meta) + } + /** * Push a validation to the validations chain. */ @@ -225,6 +329,14 @@ export class OptionalModifier> return new OptionalModifier(this.#parent.clone(), this.cloneValidations()) as this } + toJSONSchema(): JSONSchema7 & { isOptional: true } { + return { + ...this.#parent.toJSONSchema(), + // Custom property allowing object schema type to set property as not required. + isOptional: true, + } + } + /** * Compiles to compiler node */ @@ -271,6 +383,18 @@ export abstract class BaseType */ abstract [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): CompilerNodes + /** + * The child class must implement the toJSONSchema method to + * support converting into a JSON Schema. + * + * Otherwise it returns an empty schema which is considered as any. + * + * @returns JSON Schema + */ + toJSONSchema(): JSONSchema7 { + return {} + } + /** * The child class must implement the clone method to create * a deep copy of the schema instance. @@ -423,4 +547,12 @@ export abstract class BaseType nullable(): NullableModifier { return new NullableModifier(this) } + + /** + * Add meta to the field that can be retrieved once compiled. + * It is also merged with the json-schema. + */ + meta(meta: JSONSchema7): MetaModifier { + return new MetaModifier(this, meta) + } } diff --git a/src/schema/boolean/main.ts b/src/schema/boolean/main.ts index 8cf2305..9fa4e81 100644 --- a/src/schema/boolean/main.ts +++ b/src/schema/boolean/main.ts @@ -34,7 +34,7 @@ export class VineBoolean extends BaseLiteralType( } field.mutate(valueAsBoolean, field) + }, + { + toJSONSchema: (schema, { strict = false }) => { + if (strict) { + schema.type = 'boolean' + } else { + schema.enum = [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] + } + }, } ) diff --git a/src/schema/date/main.ts b/src/schema/date/main.ts index cbe2f55..c017345 100644 --- a/src/schema/date/main.ts +++ b/src/schema/date/main.ts @@ -91,7 +91,7 @@ export class VineDate extends BaseLiteralType< return helpers.asDayJS(value, this.options.formats).dateTime.isValid() } - declare protected options: FieldOptions & DateFieldOptions + declare options: FieldOptions & DateFieldOptions constructor(options?: Partial & DateFieldOptions, validations?: Validation[]) { super(options, validations || []) diff --git a/src/schema/enum/rules.ts b/src/schema/enum/rules.ts index a483281..3856004 100644 --- a/src/schema/enum/rules.ts +++ b/src/schema/enum/rules.ts @@ -17,14 +17,23 @@ import { type FieldContext } from '@vinejs/compiler/types' */ export const enumRule = createRule<{ choices: readonly any[] | ((field: FieldContext) => readonly any[]) -}>(function enumList(value, options, field) { - const choices = typeof options.choices === 'function' ? options.choices(field) : options.choices +}>( + function enumList(value, options, field) { + const choices = typeof options.choices === 'function' ? options.choices(field) : options.choices - /** - * Report error when value is not part of the pre-defined - * options - */ - if (!choices.includes(value)) { - field.report(messages.enum, 'enum', field, { choices }) + /** + * Report error when value is not part of the pre-defined + * options + */ + if (!choices.includes(value)) { + field.report(messages.enum, 'enum', field, { choices }) + } + }, + { + // TODO: We might want to handle this differently + toJSONSchema: (schema, options) => { + if (typeof options.choices === 'function') return + schema.enum = options.choices as any[] + }, } -}) +) diff --git a/src/schema/literal/main.ts b/src/schema/literal/main.ts index abf955b..f41dd5b 100644 --- a/src/schema/literal/main.ts +++ b/src/schema/literal/main.ts @@ -11,12 +11,16 @@ import { equalsRule } from './rules.js' import { helpers } from '../../vine/helpers.js' import { BaseLiteralType } from '../base/literal.js' import { IS_OF_TYPE, SUBTYPE, UNIQUE_NAME } from '../../symbols.js' -import type { FieldOptions, Literal, Validation } from '../../types.js' +import type { FieldOptions, Literal, Validation, WithJSONSchema } from '../../types.js' +import { type JSONSchema7 } from 'json-schema' /** * VineLiteral represents a type that matches an exact value */ -export class VineLiteral extends BaseLiteralType { +export class VineLiteral + extends BaseLiteralType + implements WithJSONSchema +{ /** * Default collection of literal rules */ @@ -57,4 +61,28 @@ export class VineLiteral extends BaseLiteralType { +export class VineNull implements ConstructableSchema, WithJSONSchema { /** * The input type of the schema */ @@ -76,6 +82,10 @@ export class VineNull implements ConstructableSchema { return new VineNull(this.cloneOptions()) as this } + toJSONSchema(): JSONSchema7 { + return { type: 'null' } + } + /** * Compiles the schema type to a compiler node */ diff --git a/src/schema/number/main.ts b/src/schema/number/main.ts index fd31eec..5d992b9 100644 --- a/src/schema/number/main.ts +++ b/src/schema/number/main.ts @@ -43,7 +43,7 @@ import { * }) */ export class VineNumber extends BaseLiteralType { - declare protected options: FieldOptions & { strict?: boolean } + declare options: FieldOptions & { strict?: boolean } /** * Static collection of all available validation rules for numbers diff --git a/src/schema/number/rules.ts b/src/schema/number/rules.ts index a3d1afc..3e8eb38 100644 --- a/src/schema/number/rules.ts +++ b/src/schema/number/rules.ts @@ -15,44 +15,65 @@ import { createRule } from '../../vine/create_rule.js' * Enforce the value to be a number or a string representation * of a number */ -export const numberRule = createRule<{ strict?: boolean }>(function number(value, options, field) { - if (!field.isDefined) { - return false - } +export const numberRule = createRule<{ strict?: boolean }>( + function number(value, options, field) { + if (!field.isDefined) { + return false + } - const valueAsNumber = options.strict ? value : helpers.asNumber(value) + const valueAsNumber = options.strict ? value : helpers.asNumber(value) - if ( - typeof valueAsNumber !== 'number' || - Number.isNaN(valueAsNumber) || - valueAsNumber === Number.POSITIVE_INFINITY || - valueAsNumber === Number.NEGATIVE_INFINITY - ) { - field.report(messages.number, 'number', field) - return false - } + if ( + typeof valueAsNumber !== 'number' || + Number.isNaN(valueAsNumber) || + valueAsNumber === Number.POSITIVE_INFINITY || + valueAsNumber === Number.NEGATIVE_INFINITY + ) { + field.report(messages.number, 'number', field) + return false + } - field.mutate(valueAsNumber, field) - return true -}) + field.mutate(valueAsNumber, field) + return true + }, + { + toJSONSchema: (schema) => { + schema.type = 'number' + }, + } +) /** * Enforce a minimum value on a number field */ -export const minRule = createRule<{ min: number }>(function min(value, options, field) { - if ((value as number) < options.min) { - field.report(messages.min, 'min', field, options) +export const minRule = createRule<{ min: number }>( + function min(value, options, field) { + if ((value as number) < options.min) { + field.report(messages.min, 'min', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.minimum = options.min + }, } -}) +) /** * Enforce a maximum value on a number field */ -export const maxRule = createRule<{ max: number }>(function max(value, options, field) { - if ((value as number) > options.max) { - field.report(messages.max, 'max', field, options) +export const maxRule = createRule<{ max: number }>( + function max(value, options, field) { + if ((value as number) > options.max) { + field.report(messages.max, 'max', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.maximum = options.max + }, } -}) +) /** * Enforce a range of values on a number field. @@ -62,6 +83,12 @@ export const rangeRule = createRule<{ min: number; max: number }>( if ((value as number) < options.min || (value as number) > options.max) { field.report(messages.range, 'range', field, options) } + }, + { + toJSONSchema: (schema, options) => { + schema.minimum = options.min + schema.maximum = options.max + }, } ) @@ -71,12 +98,19 @@ export const rangeRule = createRule<{ min: number; max: number }>( * * A number greater than (0). For example, (1,2,3,0.5). */ -export const positiveRule = createRule(function positive(value, _, field) { - if ((value as number) > 0) { - return +export const positiveRule = createRule( + function positive(value, _, field) { + if ((value as number) > 0) { + return + } + field.report(messages.positive, 'positive', field) + }, + { + toJSONSchema: (schema) => { + schema.minimum = 0 + }, } - field.report(messages.positive, 'positive', field) -}) +) /** * Enforce the value is a negative number. Zero is considered a neutral @@ -84,12 +118,19 @@ export const positiveRule = createRule(function positive(value, _, field) { * * A number less than (0). For example, (-1,-2,-3,-0.5) */ -export const negativeRule = createRule(function negative(value, _, field) { - if ((value as number) < 0) { - return +export const negativeRule = createRule( + function negative(value, _, field) { + if ((value as number) < 0) { + return + } + field.report(messages.negative, 'negative', field) + }, + { + toJSONSchema: (schema) => { + schema.exclusiveMaximum = 0 + }, } - field.report(messages.negative, 'negative', field) -}) +) /** * A number that is either positive or zero (greater than or equal to (0)). @@ -116,6 +157,7 @@ export const nonPositiveRule = createRule(function nonPositive(value, _, field) /** * Enforce the value to have a fixed or range of decimals */ +// TODO: Handle json-schema. Range can be handled with anyOf but we have floating point precision issues export const decimalRule = createRule<{ range: [number, number?] }>( function decimal(value, options, field) { if ( @@ -132,17 +174,31 @@ export const decimalRule = createRule<{ range: [number, number?] }>( /** * Enforce the value to not have decimal places */ -export const withoutDecimalsRule = createRule(function withoutDecimals(value, _, field) { - if (!Number.isInteger(value)) { - field.report(messages.withoutDecimals, 'withoutDecimals', field) +export const withoutDecimalsRule = createRule( + function withoutDecimals(value, _, field) { + if (!Number.isInteger(value)) { + field.report(messages.withoutDecimals, 'withoutDecimals', field) + } + }, + { + toJSONSchema: (schema) => { + schema.type = 'integer' + }, } -}) +) /** * Enforce the value to be in a list of allowed values */ -export const inRule = createRule<{ values: number[] }>(function inValues(value, options, field) { - if (!options.values.includes(value as number)) { - field.report(messages['number.in'], 'in', field, options) +export const inRule = createRule<{ values: number[] }>( + function inValues(value, options, field) { + if (!options.values.includes(value as number)) { + field.report(messages['number.in'], 'in', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.enum = options.values + }, } -}) +) diff --git a/src/schema/object/conditional.ts b/src/schema/object/conditional.ts index abf4d83..0aa2768 100644 --- a/src/schema/object/conditional.ts +++ b/src/schema/object/conditional.ts @@ -10,7 +10,8 @@ import type { ConditionalFn, ObjectGroupNode, RefsStore } from '@vinejs/compiler/types' import { OTYPE, COTYPE, PARSE, ITYPE } from '../../symbols.js' -import type { ParserOptions, SchemaTypes } from '../../types.js' +import type { ParserOptions, SchemaTypes, WithJSONSchema } from '../../types.js' +import { type JSONSchema7 } from 'json-schema' /** * Group conditional represents a sub-set of object wrapped @@ -21,7 +22,7 @@ export class GroupConditional< Input, Output, CamelCaseOutput, -> { +> implements WithJSONSchema { declare [ITYPE]: Input; declare [OTYPE]: Output; declare [COTYPE]: CamelCaseOutput @@ -41,6 +42,29 @@ export class GroupConditional< this.#conditional = conditional } + /** + * Transforms into JSONSchema. + */ + toJSONSchema(): JSONSchema7 { + const properties: Record = {} + const required: string[] = [] + + for (const [key, property] of Object.entries(this.#properties)) { + const schema = property.toJSONSchema() + properties[key] = schema + + if (!('isOptional' in property) || property.isOptional !== true) { + required.push(key) + } + } + + return { + type: 'object', + properties, + required, + } + } + /** * Compiles to a union conditional */ diff --git a/src/schema/object/group.ts b/src/schema/object/group.ts index d2674b4..a216e77 100644 --- a/src/schema/object/group.ts +++ b/src/schema/object/group.ts @@ -12,14 +12,17 @@ import { type ObjectGroupNode, type RefsStore } from '@vinejs/compiler/types' import { messages } from '../../defaults.js' import { type GroupConditional } from './conditional.js' import { ITYPE, OTYPE, COTYPE, PARSE } from '../../symbols.js' -import type { ParserOptions, UnionNoMatchCallback } from '../../types.js' +import type { ParserOptions, UnionNoMatchCallback, WithJSONSchema } from '../../types.js' +import { type JSONSchema7 } from 'json-schema' /** * Object group represents a group with multiple conditionals, where each * condition returns a set of object properties to merge into the * existing object. */ -export class ObjectGroup> { +export class ObjectGroup< + Conditional extends GroupConditional, +> implements WithJSONSchema { declare [ITYPE]: Conditional[typeof ITYPE]; declare [OTYPE]: Conditional[typeof OTYPE]; declare [COTYPE]: Conditional[typeof COTYPE] @@ -33,6 +36,15 @@ export class ObjectGroup conditional.toJSONSchema()), + } + } + /** * Clones the ObjectGroup schema type. */ diff --git a/src/schema/object/main.ts b/src/schema/object/main.ts index 5a05680..2785d4f 100644 --- a/src/schema/object/main.ts +++ b/src/schema/object/main.ts @@ -29,7 +29,9 @@ import type { ParserOptions, PropertiesToOptional, UndefinedOptional, + WithJSONSchema, } from '../../types.js' +import { type JSONSchema7 } from 'json-schema' import type { CamelCase } from '../camelcase_types.ts' /** @@ -92,6 +94,11 @@ export class VineCamelCaseObject> return new VineCamelCaseObject(this.#schema.clone()) as this } + toJSONSchema(): JSONSchema7 { + // TODO: We might want to camel case properties here aswell + return this.#schema.toJSONSchema() + } + /** * Compiles the schema type to a compiler node with camelCase enabled. * @@ -133,7 +140,10 @@ export class VineObject< Input, Output, CamelCaseOutput, -> extends BaseType { +> + extends BaseType + implements WithJSONSchema +{ /** * Object properties mapping property names to their validation schemas */ @@ -283,7 +293,42 @@ export class VineObject< return new VineCamelCaseObject(this) } - /** + toJSONSchema(): JSONSchema7 { + const properties: Record = {} + const required: string[] = [] + + for (const [key, property] of Object.entries(this.getProperties())) { + if (!property.toJSONSchema) continue + const schema = property.toJSONSchema() + properties[key] = schema + + if (!('isOptional' in schema) || schema.isOptional !== true) { + required.push(key) + } + } + + const schema: JSONSchema7 = { + type: 'object', + properties, + required, + additionalProperties: this.#allowUnknownProperties, + } + + for (const validation of this.validations) { + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) + } + + if (this.#groups.length > 0) { + return { + anyOf: [...this.#groups.map((group) => group.toJSONSchema()), schema], + } + } + + return schema + } + + /* * Creates a new object with all properties marked as optional. */ partial< diff --git a/src/schema/optional/main.ts b/src/schema/optional/main.ts index 3f8e0b3..232cf6f 100644 --- a/src/schema/optional/main.ts +++ b/src/schema/optional/main.ts @@ -28,6 +28,7 @@ import type { ConstructableSchema, } from '../../types.js' import { ConditionalValidations } from '../base/conditional_rules.js' +import { type JSONSchema7 } from 'json-schema' /** * Specify an optional value inside a union. @@ -170,6 +171,13 @@ export class VineOptional }) } + toJSONSchema(): JSONSchema7 & { isOptional: true } { + return { + // Custom property allowing object schema type to set property as not required. + isOptional: true, + } + } + /** * Compiles the schema type to a compiler node */ diff --git a/src/schema/record/main.ts b/src/schema/record/main.ts index cd7bc5d..7c079bb 100644 --- a/src/schema/record/main.ts +++ b/src/schema/record/main.ts @@ -19,18 +19,28 @@ import { UNIQUE_NAME, IS_OF_TYPE, } from '../../symbols.js' -import type { FieldOptions, ParserOptions, SchemaTypes, Validation } from '../../types.js' +import type { + FieldOptions, + ParserOptions, + SchemaTypes, + Validation, + WithJSONSchema, +} from '../../types.js' import { fixedLengthRule, maxLengthRule, minLengthRule, validateKeysRule } from './rules.js' +import { type JSONSchema7 } from 'json-schema' /** * VineRecord represents an object of key-value pair in which * keys are unknown */ -export class VineRecord extends BaseType< - { [K: string]: Schema[typeof ITYPE] }, - { [K: string]: Schema[typeof OTYPE] }, - { [K: string]: Schema[typeof COTYPE] } -> { +export class VineRecord + extends BaseType< + { [K: string]: Schema[typeof ITYPE] }, + { [K: string]: Schema[typeof OTYPE] }, + { [K: string]: Schema[typeof COTYPE] } + > + implements WithJSONSchema +{ /** * Default collection of record rules */ @@ -101,6 +111,25 @@ export class VineRecord extends BaseType< ) as this } + /** + * Transforms into JSONSchema. + */ + toJSONSchema() { + const schema = { + type: 'object', + additionalProperties: {}, + } satisfies JSONSchema7 + + schema.additionalProperties = this.#schema.toJSONSchema?.() ?? {} + + for (const validation of this.validations) { + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) + } + + return schema + } + /** * Compiles to record data type */ diff --git a/src/schema/record/rules.ts b/src/schema/record/rules.ts index 9da10c7..1864b9a 100644 --- a/src/schema/record/rules.ts +++ b/src/schema/record/rules.ts @@ -14,26 +14,40 @@ import { createRule } from '../../vine/create_rule.js' /** * Enforce a minimum length on an object field */ -export const minLengthRule = createRule<{ min: number }>(function minLength(value, options, field) { - /** - * Value will always be an object if the field is valid. - */ - if (Object.keys(value as Record).length < options.min) { - field.report(messages['record.minLength'], 'record.minLength', field, options) +export const minLengthRule = createRule<{ min: number }>( + function minLength(value, options, field) { + /** + * Value will always be an object if the field is valid. + */ + if (Object.keys(value as Record).length < options.min) { + field.report(messages['record.minLength'], 'record.minLength', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.minProperties = options.min + }, } -}) +) /** * Enforce a maximum length on an object field */ -export const maxLengthRule = createRule<{ max: number }>(function maxLength(value, options, field) { - /** - * Value will always be an object if the field is valid. - */ - if (Object.keys(value as Record).length > options.max) { - field.report(messages['record.maxLength'], 'record.maxLength', field, options) +export const maxLengthRule = createRule<{ max: number }>( + function maxLength(value, options, field) { + /** + * Value will always be an object if the field is valid. + */ + if (Object.keys(value as Record).length > options.max) { + field.report(messages['record.maxLength'], 'record.maxLength', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.maxProperties = options.max + }, } -}) +) /** * Enforce a fixed length on an object field @@ -46,6 +60,12 @@ export const fixedLengthRule = createRule<{ size: number }>( if (Object.keys(value as Record).length !== options.size) { field.report(messages['record.fixedLength'], 'record.fixedLength', field, options) } + }, + { + toJSONSchema: (schema, options) => { + schema.minProperties = options.size + schema.maxProperties = options.size + }, } ) diff --git a/src/schema/string/rules.ts b/src/schema/string/rules.ts index 68c202c..b046c0b 100644 --- a/src/schema/string/rules.ts +++ b/src/schema/string/rules.ts @@ -33,18 +33,25 @@ import type { /** * Validates the value to be a string */ -export const stringRule = createRule(function string(value, _, field) { - if (!field.isDefined) { - return false - } +export const stringRule = createRule( + function string(value, _, field) { + if (!field.isDefined) { + return false + } - if (typeof value === 'string') { - return true - } + if (typeof value === 'string') { + return true + } - field.report(messages.string, 'string', field) - return false -}) + field.report(messages.string, 'string', field) + return false + }, + { + toJSONSchema: (schema) => { + schema.type = 'string' + }, + } +) /** * Validates the value to be a valid email address @@ -54,6 +61,11 @@ export const emailRule = createRule( if (!helpers.isEmail(value as string, options)) { field.report(messages.email, 'email', field) } + }, + { + toJSONSchema: (schema) => { + schema.format = 'email' + }, } ) @@ -79,35 +91,61 @@ export const ipAddressRule = createRule<{ version: 4 | 6 } | undefined>( if (!helpers.isIP(value as string, options?.version)) { field.report(messages.ipAddress, 'ipAddress', field) } + }, + { + toJSONSchema: (schema, options) => { + schema.format = options?.version === 6 ? 'ipv6' : 'ipv4' + }, } ) /** * Validates the value against a regular expression */ -export const regexRule = createRule(function regex(value, expression, field) { - if (!expression.test(value as string)) { - field.report(messages.regex, 'regex', field) +export const regexRule = createRule( + function regex(value, expression, field) { + if (!expression.test(value as string)) { + field.report(messages.regex, 'regex', field) + } + }, + { + toJSONSchema: (schema, options) => { + schema.pattern = options.source + }, } -}) +) /** * Validates the value to be a valid hex color code */ -export const hexCodeRule = createRule(function hexCode(value, _, field) { - if (!helpers.isHexColor(value as string)) { - field.report(messages.hexCode, 'hexCode', field) +export const hexCodeRule = createRule( + function hexCode(value, _, field) { + if (!helpers.isHexColor(value as string)) { + field.report(messages.hexCode, 'hexCode', field) + } + }, + { + toJSONSchema: (schema) => { + schema.pattern = '^#?([0-9a-f]{6}|[0-9a-f]{3}|[0-9a-f]{8})$' + }, } -}) +) /** * Validates the value to be a valid URL */ -export const urlRule = createRule(function url(value, options, field) { - if (!helpers.isURL(value as string, options)) { - field.report(messages.url, 'url', field) +export const urlRule = createRule( + function url(value, options, field) { + if (!helpers.isURL(value as string, options)) { + field.report(messages.url, 'url', field) + } + }, + { + toJSONSchema: (schema) => { + schema.format = 'uri' + }, } -}) +) /** * Validates the value to be an active URL @@ -140,6 +178,24 @@ export const alphaRule = createRule( if (!expression.test(value as string)) { field.report(messages.alpha, 'alpha', field) } + }, + { + toJSONSchema: (schema, options) => { + let characterSet = 'a-zA-Z' + if (options) { + if (options.allowSpaces) { + characterSet += '\\s' + } + if (options.allowDashes) { + characterSet += '-' + } + if (options.allowUnderscores) { + characterSet += '_' + } + } + + schema.pattern = `^[${characterSet}]+$` + }, } ) @@ -165,26 +221,58 @@ export const alphaNumericRule = createRule( if (!expression.test(value as string)) { field.report(messages.alphaNumeric, 'alphaNumeric', field) } + }, + { + toJSONSchema: (schema, options) => { + let characterSet = 'a-zA-Z0-9' + if (options) { + if (options.allowSpaces) { + characterSet += '\\s' + } + if (options.allowDashes) { + characterSet += '-' + } + if (options.allowUnderscores) { + characterSet += '_' + } + } + + schema.pattern = `^[${characterSet}]+$` + }, } ) /** * Enforce a minimum length on a string field */ -export const minLengthRule = createRule<{ min: number }>(function minLength(value, options, field) { - if ((value as string).length < options.min) { - field.report(messages.minLength, 'minLength', field, options) +export const minLengthRule = createRule<{ min: number }>( + function minLength(value, options, field) { + if ((value as string).length < options.min) { + field.report(messages.minLength, 'minLength', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.minLength = options.min + }, } -}) +) /** * Enforce a maximum length on a string field */ -export const maxLengthRule = createRule<{ max: number }>(function maxLength(value, options, field) { - if ((value as string).length > options.max) { - field.report(messages.maxLength, 'maxLength', field, options) +export const maxLengthRule = createRule<{ max: number }>( + function maxLength(value, options, field) { + if ((value as string).length > options.max) { + field.report(messages.maxLength, 'maxLength', field, options) + } + }, + { + toJSONSchema: (schema, options) => { + schema.maxLength = options.max + }, } -}) +) /** * Enforce a fixed length on a string field @@ -194,6 +282,12 @@ export const fixedLengthRule = createRule<{ size: number }>( if ((value as string).length !== options.size) { field.report(messages.fixedLength, 'fixedLength', field, options) } + }, + { + toJSONSchema: (schema, options) => { + schema.minLength = options.size + schema.maxLength = options.size + }, } ) @@ -443,17 +537,29 @@ export const uuidRule = createRule<{ version?: (1 | 2 | 3 | 4 | 5 | 6 | 7 | 8)[] field.report(messages.uuid, 'uuid', field, options) } } + }, + { + toJSONSchema: (schema) => { + schema.format = 'uuid' + }, } ) /** * Validates the value to be a valid ULID */ -export const ulidRule = createRule(function ulid(value, _, field) { - if (!helpers.isULID(value as string)) { - field.report(messages.ulid, 'ulid', field) +export const ulidRule = createRule( + function ulid(value, _, field) { + if (!helpers.isULID(value as string)) { + field.report(messages.ulid, 'ulid', field) + } + }, + { + toJSONSchema: (schema) => { + schema.pattern = '^[0-7][0-9A-HJKMNP-TV-Z]{25}$' + }, } -}) +) /** * Validates the value contains ASCII characters only diff --git a/src/schema/tuple/main.ts b/src/schema/tuple/main.ts index 7a0fe06..4f098e6 100644 --- a/src/schema/tuple/main.ts +++ b/src/schema/tuple/main.ts @@ -12,7 +12,14 @@ import { type RefsStore, type TupleNode } from '@vinejs/compiler/types' import { BaseType } from '../base/main.js' import { IS_OF_TYPE, PARSE, UNIQUE_NAME } from '../../symbols.js' -import type { FieldOptions, ParserOptions, SchemaTypes, Validation } from '../../types.js' +import type { + FieldOptions, + ParserOptions, + SchemaTypes, + Validation, + WithJSONSchema, +} from '../../types.js' +import { type JSONSchema7 } from 'json-schema' /** * VineTuple is an array with known length and may have different @@ -23,7 +30,10 @@ export class VineTuple< Input extends any[], Output extends any[], CamelCaseOutput extends any[], -> extends BaseType { +> + extends BaseType + implements WithJSONSchema +{ #schemas: [...Schema] /** @@ -84,6 +94,33 @@ export class VineTuple< return cloned as this } + /** + * Transforms into JSONSchema. + */ + toJSONSchema() { + const items: JSONSchema7[] = [] + for (const item of this.#schemas) { + if (!item.toJSONSchema) continue + items.push(item.toJSONSchema()) + } + + const schema: JSONSchema7 = { + type: 'array', + minItems: this.#schemas.length, + maxItems: this.#schemas.length, + additionalItems: false, + // Items should NEVER be a list of empty items according to standard + items: items.length > 0 ? items : undefined, + } + + for (const validation of this.validations) { + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) + } + + return schema + } + /** * Compiles to array data type */ diff --git a/src/schema/union/conditional.ts b/src/schema/union/conditional.ts index 3b88974..305e9dc 100644 --- a/src/schema/union/conditional.ts +++ b/src/schema/union/conditional.ts @@ -36,6 +36,10 @@ export class UnionConditional { this.#conditional = conditional } + toJSONSchema() { + return this.#schema.toJSONSchema?.() + } + /** * Compiles to a union conditional */ diff --git a/src/schema/union/main.ts b/src/schema/union/main.ts index cdf96b2..f4c55a8 100644 --- a/src/schema/union/main.ts +++ b/src/schema/union/main.ts @@ -18,21 +18,25 @@ import type { ParserOptions, ConstructableSchema, UnionNoMatchCallback, + WithJSONSchema, } from '../../types.js' import { VineOptional } from '../optional/main.js' import { VineNull } from '../null/main.js' +import { type JSONSchema7 } from 'json-schema' /** * Vine union represents a union data type. A union is a collection * of conditionals and each condition has an associated schema */ -export class VineUnion< - Conditional extends UnionConditional, -> implements ConstructableSchema< - Conditional[typeof ITYPE], - Conditional[typeof OTYPE], - Conditional[typeof COTYPE] -> { +export class VineUnion> + implements + ConstructableSchema< + Conditional[typeof ITYPE], + Conditional[typeof OTYPE], + Conditional[typeof COTYPE] + >, + WithJSONSchema +{ declare [ITYPE]: Conditional[typeof ITYPE]; declare [OTYPE]: Conditional[typeof OTYPE]; declare [COTYPE]: Conditional[typeof COTYPE] @@ -82,6 +86,15 @@ export class VineUnion< return this } + /** + * Transforms into JSONSchema. + */ + toJSONSchema(): JSONSchema7 { + return { + anyOf: this.#conditionals.map((conditional) => conditional.toJSONSchema()).filter(Boolean), + } + } + /** * Clones the VineUnion schema type. */ diff --git a/src/schema/union_of_types/main.ts b/src/schema/union_of_types/main.ts index 7563a43..5b5df37 100644 --- a/src/schema/union_of_types/main.ts +++ b/src/schema/union_of_types/main.ts @@ -17,19 +17,21 @@ import type { ParserOptions, ConstructableSchema, UnionNoMatchCallback, + WithJSONSchema, } from '../../types.js' import { VineOptional } from '../optional/main.js' import { VineNull } from '../null/main.js' +import { type JSONSchema7 } from 'json-schema' /** * Vine union represents a union data type. A union is a collection * of conditionals and each condition has an associated schema */ -export class VineUnionOfTypes implements ConstructableSchema< - Schema[typeof ITYPE], - Schema[typeof OTYPE], - Schema[typeof COTYPE] -> { +export class VineUnionOfTypes + implements + ConstructableSchema, + WithJSONSchema +{ declare [ITYPE]: Schema[typeof ITYPE]; declare [OTYPE]: Schema[typeof OTYPE]; declare [COTYPE]: Schema[typeof COTYPE] @@ -84,6 +86,15 @@ export class VineUnionOfTypes implements Constructab return new VineUnionOfTypes([new VineNull(), ...this.#schemas]) } + /** + * Transforms into JSONSchema. + */ + toJSONSchema(): JSONSchema7 { + return { + anyOf: this.#schemas.map((schema) => schema.toJSONSchema?.()).filter(Boolean), + } + } + /** * Compiles to a union */ diff --git a/src/types.ts b/src/types.ts index a4ef527..b2184fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,7 @@ import { type Prettify, type ExtractUndefined, type ExtractDefined } from '@popp import { type VineValidator } from './vine/validator.ts' import { type VineObject } from './schema/object/main.ts' import { type CamelCase } from './schema/camelcase_types.ts' +import { type JSONSchema7 } from 'json-schema' /** * Compiler nodes emitted by Vine during schema compilation. @@ -324,6 +325,9 @@ export interface ConstructableSchema { [UNIQUE_NAME]?: string /** Type checking function for union type resolution */ [IS_OF_TYPE]?: (value: unknown, field: FieldContext) => boolean + + /** Transforms your schema type into JSONSchema7 */ + toJSONSchema(): JSONSchema7 } /** @@ -362,6 +366,8 @@ export interface ConstructableLiteralSchema { ): LiteralNode & { subtype: string } /** Creates a deep copy of the schema instance */ clone(): this + /** Transforms your schema type into JSONSchema7 */ + toJSONSchema(): JSONSchema7 /** * Unique identifier for the schema type. @@ -396,6 +402,16 @@ export interface WithCustomRules { use(validation: Validation | RuleBuilder): this } +/** + * Interface for schema types that support converting into JSON Schema. + * + * @example + * const schema = vine.string().toJSONSchema() + */ +export interface WithJSONSchema { + toJSONSchema(): JSONSchema7 +} + /** * Union type representing all possible schema types in Vine. * Used as a constraint for generic functions that accept any schema type. @@ -438,6 +454,11 @@ export type Validator = ( field: FieldContext ) => any | Promise +export type JsonSchemaModifier = ( + schema: JSONSchema7, + options: Options +) => void + /** * A validation rule combines a validator function with metadata needed * for compilation and execution. This is the building block of all @@ -464,6 +485,7 @@ export type ValidationRule = { isAsync: boolean /** Whether the rule runs even when the field value is undefined/null */ implicit: boolean + toJSONSchema?: JsonSchemaModifier } /** diff --git a/src/vine/create_rule.ts b/src/vine/create_rule.ts index aa2e586..d80c322 100644 --- a/src/vine/create_rule.ts +++ b/src/vine/create_rule.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import type { Validation, ValidationRule, Validator } from '../types.js' +import type { JsonSchemaModifier, Validation, ValidationRule, Validator } from '../types.js' /** * Utility type that determines the argument signature for a validation rule factory. @@ -65,6 +65,7 @@ export function createRule( implicit?: boolean /** Whether the validator function is async */ isAsync?: boolean + toJSONSchema?: JsonSchemaModifier } ) { const rule: ValidationRule = { @@ -72,6 +73,7 @@ export function createRule( name: metaData?.name ?? validator.name, isAsync: metaData?.isAsync || validator.constructor.name === 'AsyncFunction', implicit: metaData?.implicit ?? false, + toJSONSchema: metaData?.toJSONSchema, } return function (...options: GetArgs): Validation { diff --git a/src/vine/helpers.ts b/src/vine/helpers.ts index ec47b4b..a9c505e 100644 --- a/src/vine/helpers.ts +++ b/src/vine/helpers.ts @@ -48,25 +48,25 @@ import { type Prettify } from '@poppinss/types' * Values that are considered true in HTML form context. * Includes boolean true, number 1, string '1', 'true', and 'on' (for checkboxes). */ -const BOOLEAN_POSITIVES = ['1', 1, 'true', true, 'on'] +export const BOOLEAN_POSITIVES = ['1', 1, 'true', true, 'on'] /** * Values that are considered false in HTML form context. * Includes boolean false, number 0, string '0', and 'false'. */ -const BOOLEAN_NEGATIVES = ['0', 0, 'false', false] +export const BOOLEAN_NEGATIVES = ['0', 0, 'false', false] /** * Default date formats used when no specific format is provided. * Supports date-only (YYYY-MM-DD) and datetime (YYYY-MM-DD HH:mm:ss) formats. */ -const DEFAULT_DATE_FORMATS = ['YYYY-MM-DD', 'YYYY-MM-DD HH:mm:ss'] +export const DEFAULT_DATE_FORMATS = ['YYYY-MM-DD', 'YYYY-MM-DD HH:mm:ss'] /** * Regular expression for validating ULID (Universally Unique Lexicographically Sortable Identifier) format. * ULIDs are 26 characters long using Crockford's Base32 encoding. */ -const ULID = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/ +export const ULID = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/ dayjs.extend(customParseFormat) dayjs.extend(isSameOrAfter) diff --git a/src/vine/validator.ts b/src/vine/validator.ts index 18c7fbc..1608f19 100644 --- a/src/vine/validator.ts +++ b/src/vine/validator.ts @@ -21,6 +21,7 @@ import type { ValidationOptions, ErrorReporterContract, } from '../types.js' +import { type JSONSchema7 } from 'json-schema' /** * Error messages to share with the compiler @@ -68,6 +69,12 @@ export class VineValidator< refs: Refs } + /** + * JSON Schema is only computed when asked. + * We cache it in validator for reusability. + */ + #jsonSchema?: JSONSchema7 + /** * Messages provider instance used for internationalization * and custom error message formatting @@ -239,6 +246,14 @@ export class VineValidator< } } + 'toJSONSchema'(): JSONSchema7 { + if (!this.#jsonSchema) { + this.#jsonSchema = this.schema.toJSONSchema() + } + + return this.#jsonSchema + } + readonly '~standard': StandardSchemaV1.Props = { version: 1, vendor: 'vinejs', diff --git a/tests/integration/json_schema.spec.ts b/tests/integration/json_schema.spec.ts new file mode 100644 index 0000000..bea4129 --- /dev/null +++ b/tests/integration/json_schema.spec.ts @@ -0,0 +1,671 @@ +import { test } from '@japa/runner' +import vine from '../../index.js' +import Ajv from 'ajv' +import { type SchemaTypes } from '../../src/types.js' +import { inspect } from 'node:util' + +const ajv = new Ajv() + +function validate(schema: SchemaTypes, value: any) { + const validator = ajv.compile(vine.create(schema).toJSONSchema()) + return validator(value) +} + +type Dataset = [title: string, validator: SchemaTypes, tests: [value: any, expected: boolean][]] + +test.group('JsonSchema', () => { + test('vine.any() - {0}') + .with([ + [ + 'base', + vine.any(), + [ + [{ HO: true }, true], + ['hello world', true], + [18391, true], + [null, false], + ], + ], + [ + 'nullable', + vine.any().nullable(), + [ + ['still works', true], + [null, true], + [undefined, false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${value} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@any']) + + test('vine.string() - {0}') + .with([ + [ + 'base', + vine.string(), + [ + ['adonisjs', true], + [28112000, false], + [null, false], + ], + ], + [ + 'nullable', + vine.string().nullable(), + [ + [null, true], + ['still works', true], + [undefined, false], + ], + ], + [ + 'minLength', + vine.string().minLength(5), + [ + ['12345', true], + ['12345678', true], + ['1234', false], + ], + ], + [ + 'maxLength', + vine.string().maxLength(5), + [ + ['1234', true], + ['12345', true], + ['12345678', false], + ], + ], + [ + 'fixedLength', + vine.string().fixedLength(5), + [ + ['12345', true], + ['1234', false], + ['12345678', false], + ], + ], + [ + 'email', + vine.string().email(), + [ + ['contact@example.org', true], + ['test', false], + ], + ], + [ + 'uuid', + vine.string().uuid(), + [ + ['e9a85a01-d20f-4309-bcda-f5319f2cb4cc', true], + ['test', false], + ], + ], + [ + 'ulid', + vine.string().ulid(), + [ + ['01K3MGCZG0HZ7XJQ3CEN6F46Q4', true], + ['test', false], + ], + ], + [ + 'alpha', + vine.string().alpha(), + [ + ['HelloWorld', true], + ['hello world', false], + ['hello_world', false], + ['hello-world', false], + ['1283', false], + ], + ], + [ + 'alpha with options', + vine.string().alpha({ allowDashes: true, allowSpaces: true, allowUnderscores: true }), + [ + ['hello_world-foo baz', true], + ['hello world 82', false], + ], + ], + [ + 'alphaNumeric', + vine.string().alphaNumeric(), + [ + ['HELLO', true], + ['H12H', true], + ['H_12', false], + ['H-12', false], + ['H 12', false], + ], + ], + [ + 'alphaNumeric with options', + vine + .string() + .alphaNumeric({ allowDashes: true, allowSpaces: true, allowUnderscores: true }), + [ + ['HELLO', true], + ['H12H', true], + ['H_12', true], + ['H-12', true], + ['H 12', true], + ], + ], + [ + 'ipv4', + vine.string().ipAddress(), + [ + ['10.0.0.0', true], + ['10.0.0.0.0', false], + ['694f:a349:210b:6dc4:f2e3:e3b6:0737:bcf9', false], + ], + ], + [ + 'ipv6', + vine.string().ipAddress(6), + [ + ['694f:a349:210b:6dc4:f2e3:e3b6:0737:bcf9', true], + ['10.0.0.0', false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${value} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@string']) + + test('vine.number() - {0}') + .with([ + [ + 'base', + vine.number(), + [ + [28112000, true], + ['12348', false], + ['adonisjs', false], + [null, false], + ], + ], + [ + 'nullable', + vine.number().nullable(), + [ + [28112000, true], + [null, true], + ], + ], + [ + 'min', + vine.number().min(4), + [ + [4, true], + [4.801, true], + [2, false], + [-5, false], + [12345, true], + ], + ], + [ + 'max', + vine.number().max(4), + [ + [4, true], + [3.99, true], + [2, true], + [-10, true], + [8, false], + [12346, false], + ], + ], + [ + 'range', + vine.number().range([10, 20]), + [ + [10, true], + [20, true], + [15, true], + [10.1, true], + [2, false], + [25, false], + ], + ], + [ + 'positive', + vine.number().positive(), + [ + [0, true], + [100, true], + [0.12, true], + [-20, false], + ], + ], + [ + 'negative', + vine.number().negative(), + [ + [-100, true], + [-0.1, true], + [0, false], + [0.5, false], + [20, false], + ], + ], + [ + 'withoutDecimals', + vine.number().withoutDecimals(), + [ + [1381, true], + [-1381, true], + [10.5, false], + [-10.5, false], + ], + ], + [ + 'in', + vine.number().in([10, 38.2, -50]), + [ + [10, true], + [10.2, false], + [38.2, true], + [38, false], + [-50, true], + [50, false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${value} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@number']) + + test('vine.enum() - {0}') + .with([ + [ + 'no type', + vine.enum([1, 'test', false]), + [ + [1, true], + [2, false], + ['test', true], + ['hello', false], + [false, true], + [true, false], + [null, false], + ], + ], + [ + 'nullable', + vine.enum([1, 'test', false]).nullable(), + [ + [1, true], + ['test', true], + [false, true], + [null, true], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${value} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@enum']) + + test('vine.boolean() - {0}') + .with([ + [ + 'not strict', + vine.boolean(), + [ + [true, true], + [false, true], + [0, true], + [1, true], + ['on', true], + [null, false], + ], + ], + [ + 'strict', + vine.boolean({ strict: true }), + [ + [true, true], + [false, true], + [0, false], + [1, false], + ['on', false], + [null, false], + ], + ], + [ + 'not strict nullable', + vine.boolean().nullable(), + [ + [true, true], + [false, true], + [0, true], + [1, true], + ['on', true], + [null, true], + ], + ], + [ + 'strict nullable', + vine.boolean({ strict: true }).nullable(), + [ + [true, true], + [false, true], + [0, false], + [1, false], + ['on', false], + [null, true], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${value} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@boolean']) + + test('vine.record() - {0}') + .with([ + [ + 'base', + vine.record(vine.string()), + [ + [{ hello: 'world' }, true], + [{}, true], + [{ foo: false }, false], + ['notarecord', false], + [null, false], + ], + ], + [ + 'nullable', + vine.record(vine.string()).nullable(), + [ + [{ hello: 'world' }, true], + [{}, true], + [{ foo: false }, false], + ['notarecord', false], + [null, true], + ], + ], + [ + 'minLength', + vine.record(vine.string()).minLength(2), + [ + [{ hello: 'world', foo: 'baz' }, true], + [{ hell: 'world', foo: 'baz', baz: 'foo' }, true], + [{ hello: 'world' }, false], + ], + ], + [ + 'maxLength', + vine.record(vine.string()).maxLength(2), + [ + [{ hello: 'world', foo: 'baz' }, true], + [{ hello: 'world' }, true], + [{}, true], + [{ hell: 'world', foo: 'baz', baz: 'foo' }, false], + ], + ], + [ + 'fixedLength', + vine.record(vine.string()).fixedLength(2), + [ + [{ hello: 'world', foo: 'baz' }, true], + [{ hello: 'world' }, false], + [{}, false], + [{ hell: 'world', foo: 'baz', baz: 'foo' }, false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${value} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@record']) + + test('vine.object() - {0}') + .with([ + [ + 'base', + vine.object({ hello: vine.string(), foo: vine.number().optional() }), + [ + [{ hello: 'world' }, true], + [{ hello: 'world', foo: 8 }, true], + [{ hello: 'world', heey: true }, false], + [{ foo: 5 }, false], + [false, false], + [null, false], + ], + ], + [ + 'allowUnknownProperties', + vine.object({ hello: vine.string() }).allowUnknownProperties(), + [ + [{ hello: 'world' }, true], + [{ hello: 'world', foo: 8 }, true], + ], + ], + [ + 'empty', + vine.object({}), + [ + [{}, true], + [{ hello: 'world' }, false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${inspect(value)} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@object']) + + test('vine.array() - {0}') + .with([ + [ + 'base', + vine.array(vine.string()), + [ + [['hello', 'world'], true], + [[], true], + [{ 0: 'hey' }, false], + [false, false], + [null, false], + ], + ], + [ + 'nullable', + vine.array(vine.boolean()).nullable(), + [ + [[true, false], true], + [null, true], + [[true, false, null], false], + ], + ], + [ + 'minLength', + vine.array(vine.string()).minLength(2), + [ + [['hello', 'world'], true], + [['hello', 'world', 'foo'], true], + [['hello'], false], + ], + ], + [ + 'maxLength', + vine.array(vine.string()).maxLength(2), + [ + [['hello', 'world'], true], + [['hello'], true], + [[], true], + [['hello', 'world', 'foo'], false], + ], + ], + [ + 'fixedLength', + vine.array(vine.string()).fixedLength(2), + [ + [['hello', 'world'], true], + [['hello'], false], + [[], false], + [['hello', 'world', 'foo'], false], + ], + ], + [ + 'notEmpty', + vine.array(vine.string()).notEmpty(), + [ + [['hello', 'world'], true], + [[], false], + ], + ], + [ + 'distinct', + vine.array(vine.string()).distinct(), + [ + [['hello', 'world'], true], + [['hello', 'world', 'hello'], false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${inspect(value)} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@array']) + + test('vine.tuple() - {0}') + .with([ + [ + 'base', + vine.tuple([vine.string(), vine.number()]), + [ + [['hello', 5], true], + [['hello'], false], + [[5, 'hello'], false], + [[5, null], false], + [null, false], + ], + ], + [ + 'nullable', + vine.tuple([vine.string(), vine.number()]).nullable(), + [ + [['hello', 5], true], + [null, true], + ], + ], + [ + 'empty', + vine.tuple([]).nullable(), + [ + [[], true], + [[null], false], + [['test'], false], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${inspect(value)} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@tuple']) + + test('vine.literal() - {0}') + .with([ + [ + 'base', + vine.literal('hello'), + [ + ['hello', true], + ['hell', false], + [null, false], + ], + ], + [ + 'nullable', + vine.literal(1).nullable(), + [ + [1, true], + [true, false], + [null, true], + ], + ], + ] as Dataset[]) + .run(({ assert }, [, validator, tests]) => { + for (const [value, expected] of tests) { + const result = validate(validator, value) + assert.equal( + result, + expected, + `Expected ${inspect(value)} validation to be ${expected} but got ${result}` + ) + } + }) + .tags(['@literal']) +}) diff --git a/tests/unit/json_schema.spec.ts b/tests/unit/json_schema.spec.ts new file mode 100644 index 0000000..1047fa8 --- /dev/null +++ b/tests/unit/json_schema.spec.ts @@ -0,0 +1,538 @@ +import { test } from '@japa/runner' +import vine from '../../index.js' +import { type SchemaTypes } from '../../src/types.js' +import { type JSONSchema7 } from 'json-schema' +import { BOOLEAN_NEGATIVES, BOOLEAN_POSITIVES } from '../../src/vine/helpers.js' +import { createRule } from '../../src/vine/create_rule.ts' + +enum Roles { + ADMIN = 'admin', + MOD = 'moderator', +} + +test.group('JsonSchema', () => { + test('vine.string().{0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['', vine.string(), { type: 'string' }], + ['minLength(2)', vine.string().minLength(2), { type: 'string', minLength: 2 }], + ['maxLength(8)', vine.string().maxLength(8), { type: 'string', maxLength: 8 }], + [ + 'fixedLength(4)', + vine.string().fixedLength(4), + { type: 'string', minLength: 4, maxLength: 4 }, + ], + ['email()', vine.string().email(), { type: 'string', format: 'email' }], + ['uuid()', vine.string().uuid(), { type: 'string', format: 'uuid' }], + [ + 'ulid()', + vine.string().ulid(), + { type: 'string', pattern: '^[0-7][0-9A-HJKMNP-TV-Z]{25}$' }, + ], + ['alpha()', vine.string().alpha(), { type: 'string', pattern: '^[a-zA-Z]+$' }], + [ + 'alphaNumeric()', + vine.string().alphaNumeric(), + { type: 'string', pattern: '^[a-zA-Z0-9]+$' }, + ], + [ + 'hexcode()', + vine.string().hexCode(), + { type: 'string', pattern: '^#?([0-9a-f]{6}|[0-9a-f]{3}|[0-9a-f]{8})$' }, + ], + [ + 'regex(/hello[a-z]/)', + vine.string().regex(/hello[a-z]/), + { type: 'string', pattern: 'hello[a-z]' }, + ], + ['ipAddress()', vine.string().ipAddress(), { type: 'string', format: 'ipv4' }], + ['nullable()', vine.string().nullable(), { type: ['string', 'null'] }], + [ + 'meta({ description: "Hello Virk" })', + vine.string().meta({ description: 'Hello Virk' }), + { type: 'string', description: 'Hello Virk' }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + test('vine.number().{0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['', vine.number(), { type: 'number' }], + ['min(2)', vine.number().min(2), { type: 'number', minimum: 2 }], + ['max(8)', vine.number().max(8), { type: 'number', maximum: 8 }], + [ + 'range(12, 36)', + vine.number().range([12, 36]), + { type: 'number', minimum: 12, maximum: 36 }, + ], + ['positive()', vine.number().positive(), { type: 'number', minimum: 0 }], + ['negative()', vine.number().negative(), { type: 'number', exclusiveMaximum: 0 }], + ['withoutDecimals()', vine.number().withoutDecimals(), { type: 'integer' }], + ['in([3, 1, 8])', vine.number().in([3, 1, 8]), { type: 'number', enum: [3, 1, 8] }], + [ + 'meta({ example: [1] })', + vine.number().meta({ examples: [1] }), + { type: 'number', examples: [1] }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@string']) + + test('vine.enum() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['no type', vine.enum([1, 3]), { enum: [1, 3] }], + ['native enum', vine.enum(Roles), { enum: ['admin', 'moderator'] }], + ['nullable', vine.enum([1, 3]).nullable(), { anyOf: [{ enum: [1, 3] }, { type: 'null' }] }], + [ + 'nullable with predifined type', + vine.enum(['foo', 'baz']).meta({ type: 'string' }).nullable(), + { anyOf: [{ type: 'string', enum: ['foo', 'baz'] }, { type: 'null' }] }, + ], + [ + 'meta', + vine.enum(Roles).meta({ default: Roles.ADMIN }), + { + enum: ['admin', 'moderator'], + default: 'admin', + }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@enum']) + + test('vine.boolean() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['not strict', vine.boolean(), { enum: [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] }], + ['strict', vine.boolean({ strict: true }), { type: 'boolean' }], + ['strict nullable', vine.boolean({ strict: true }).nullable(), { type: ['boolean', 'null'] }], + [ + 'not strict nullable', + vine.boolean().nullable(), + { anyOf: [{ enum: [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] }, { type: 'null' }] }, + ], + [ + 'meta', + vine.boolean({ strict: true }).meta({ examples: [true] }), + { type: 'boolean', examples: [true] }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@boolean']) + + test('vine.any() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + [ + '', + vine.any(), + { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + { type: 'object' }, + ], + }, + ], + [ + 'nullable', + vine.any().nullable(), + { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + { type: 'object' }, + { type: 'null' }, + ], + }, + ], + [ + 'meta', + vine.any().meta({ examples: ['ANYTHING'] }), + { + examples: ['ANYTHING'], + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + { type: 'object' }, + ], + }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@any']) + + test('vine.record().{0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + [ + '', + vine.record(vine.string()), + { type: 'object', additionalProperties: { type: 'string' } }, + ], + [ + 'nullable()', + vine.record(vine.number()).nullable(), + { type: ['object', 'null'], additionalProperties: { type: 'number' } }, + ], + [ + 'minLength(2)', + vine.record(vine.number()).minLength(2), + { type: 'object', additionalProperties: { type: 'number' }, minProperties: 2 }, + ], + [ + 'maxLength(12)', + vine.record(vine.number()).minLength(12), + { type: 'object', additionalProperties: { type: 'number' }, minProperties: 12 }, + ], + [ + 'fixedLength(6)', + vine.record(vine.number()).fixedLength(6), + { + type: 'object', + additionalProperties: { type: 'number' }, + minProperties: 6, + maxProperties: 6, + }, + ], + [ + 'meta', + vine.record(vine.number()).meta({ examples: [1, 2, 3] }), + { + type: 'object', + additionalProperties: { type: 'number' }, + examples: [1, 2, 3], + }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@record']) + + test('vine.object() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + [ + 'empty', + vine.object({}), + { type: 'object', properties: {}, required: [], additionalProperties: false }, + ], + [ + 'properties', + vine.object({ hello: vine.string(), world: vine.number() }), + { + type: 'object', + properties: { + hello: { + type: 'string', + }, + world: { + type: 'number', + }, + }, + required: ['hello', 'world'], + additionalProperties: false, + }, + ], + [ + 'allowUnknownProperties', + vine.object({ hello: vine.string() }).allowUnknownProperties(), + { + type: 'object', + properties: { + hello: { + type: 'string', + }, + }, + required: ['hello'], + additionalProperties: true, + }, + ], + [ + 'optional properties', + vine.object({ + foo: vine.number().optional(), + baz: vine.string(), + }), + { + type: 'object', + properties: { + foo: { + type: 'number', + // @ts-expect-error -- this is not part of standard (used for context) + isOptional: true, + }, + baz: { + type: 'string', + }, + }, + required: ['baz'], + additionalProperties: false, + }, + ], + [ + 'nullable', + vine.object({}).nullable(), + { type: ['object', 'null'], properties: {}, required: [], additionalProperties: false }, + ], + [ + 'meta', + vine.object({}).meta({ description: 'Hello World!' }), + { + type: 'object', + description: 'Hello World!', + properties: {}, + required: [], + additionalProperties: false, + }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@object']) + + test('vine.array() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['', vine.array(vine.string()), { type: 'array', items: { type: 'string' } }], + [ + 'minLength(2)', + vine.array(vine.string()).minLength(2), + { type: 'array', items: { type: 'string' }, minItems: 2 }, + ], + [ + 'maxLength(8)', + vine.array(vine.string()).maxLength(8), + { type: 'array', items: { type: 'string' }, maxItems: 8 }, + ], + [ + 'fixedLength(8)', + vine.array(vine.string()).fixedLength(8), + { type: 'array', items: { type: 'string' }, minItems: 8, maxItems: 8 }, + ], + [ + 'notEmpty()', + vine.array(vine.string()).notEmpty(), + { type: 'array', items: { type: 'string' }, minItems: 1 }, + ], + [ + 'distinct()', + vine.array(vine.string()).distinct(), + { type: 'array', items: { type: 'string' }, uniqueItems: true }, + ], + [ + 'nullable', + vine.array(vine.string()).nullable(), + { type: ['array', 'null'], items: { type: 'string' } }, + ], + [ + 'meta', + vine.array(vine.boolean({ strict: true })).meta({ examples: [[true, false, false]] }), + { type: 'array', items: { type: 'boolean' }, examples: [[true, false, false]] }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@array']) + + test('vine.tuple() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + [ + '', + vine.tuple([vine.string(), vine.number()]), + { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + minItems: 2, + maxItems: 2, + additionalItems: false, + }, + ], + // TODO: allowUnknownProperties() + [ + 'nullable', + vine.tuple([vine.string()]).nullable(), + { + type: ['array', 'null'], + items: [{ type: 'string' }], + minItems: 1, + maxItems: 1, + additionalItems: false, + }, + ], + [ + 'meta', + vine + .tuple([vine.boolean({ strict: true }), vine.number()]) + .meta({ description: 'A tuple' }), + { + type: 'array', + items: [{ type: 'boolean' }, { type: 'number' }], + minItems: 2, + maxItems: 2, + additionalItems: false, + description: 'A tuple', + }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@tuple']) + + test('vine.literal() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['string', vine.literal('literal_string'), { type: 'string', enum: ['literal_string'] }], + ['number', vine.literal(2481), { type: 'number', enum: [2481] }], + ['boolean', vine.literal(true), { type: 'boolean', enum: [true] }], + [ + 'nullable', + vine.literal('str').nullable(), + { + anyOf: [{ type: 'string', enum: ['str'] }, { type: 'null' }], + }, + ], + [ + 'meta', + vine.literal(false).meta({ description: 'Always false' }), + { type: 'boolean', enum: [false], description: 'Always false' }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.create(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + .tags(['@literal']) + + test('vine.union() - {0}') + .run(({ assert }) => { + const schema = vine.union([ + vine.union.if((value) => vine.helpers.isString(value), vine.string().email()), + vine.union.if( + (value) => vine.helpers.isObject(value), + vine.object({ email: vine.string().email() }) + ), + ]) + + const validator = vine.create(schema) + + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [ + { type: 'string', format: 'email' }, + { + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'], + additionalProperties: false, + }, + ], + }) + }) + .tags(['@union']) + + test('vine.unionOfTypes() - {0}') + .run(({ assert }) => { + const schema = vine.unionOfTypes([vine.string().email(), vine.number()]) + + const validator = vine.create(schema) + + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [{ type: 'string', format: 'email' }, { type: 'number' }], + }) + }) + .tags(['@unionOfTypes']) + + test('vine.group()') + .run(({ assert }) => { + const guideSchema = vine.group([ + vine.group.if((data) => vine.helpers.isTrue(data.is_hiring_guide), { + is_hiring_guide: vine.literal(true), + guide_id: vine.string(), + amount: vine.number(), + }), + vine.group.else({ + is_hiring_guide: vine.literal(false), + }), + ]) + + const schema = vine + .object({ + name: vine.string(), + group_size: vine.number(), + phone_number: vine.string(), + }) + .merge(guideSchema) + + const validator = vine.create(schema) + + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + is_hiring_guide: { type: 'boolean', enum: [true] }, + guide_id: { type: 'string' }, + amount: { type: 'number' }, + }, + required: ['is_hiring_guide', 'guide_id', 'amount'], + }, + { + type: 'object', + properties: { + is_hiring_guide: { type: 'boolean', enum: [false] }, + }, + required: ['is_hiring_guide'], + }, + ], + }, + { + type: 'object', + properties: { + name: { type: 'string' }, + group_size: { type: 'number' }, + phone_number: { type: 'string' }, + }, + required: ['name', 'group_size', 'phone_number'], + additionalProperties: false, + }, + ], + } satisfies JSONSchema7) + }) + .tags(['@group']) + + test('allow custom rules to modify schema', ({ assert }) => { + const rule = createRule(() => {}, { + toJSONSchema: (schema) => { + schema.type = 'string' + }, + }) + + assert.deepEqual(vine.number().use(rule()).toJSONSchema(), { + type: 'string', + }) + }) +})