From dc5639cb97176536d6821381c990ea40180dac7e Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Sat, 5 Jul 2025 21:49:58 +0200 Subject: [PATCH 1/7] feat: implement json-schema support --- package.json | 1 + src/schema/any/main.ts | 38 ++++- src/schema/array/main.ts | 33 +++- src/schema/array/rules.ts | 166 +++++++++++------- src/schema/base/literal.ts | 85 ++++++++++ src/schema/base/main.ts | 61 +++++++ src/schema/boolean/rules.ts | 24 ++- src/schema/enum/rules.ts | 27 ++- src/schema/number/rules.ts | 257 +++++++++++++++++----------- src/schema/object/main.ts | 39 ++++- src/schema/record/main.ts | 32 +++- src/schema/record/rules.ts | 100 ++++++----- src/schema/string/rules.ts | 302 +++++++++++++++++++++++---------- src/schema/tuple/main.ts | 32 +++- src/types.ts | 7 + src/vine/create_rule.ts | 4 +- src/vine/validator.ts | 11 ++ tests/unit/json_schema.spec.ts | 291 +++++++++++++++++++++++++++++++ 18 files changed, 1183 insertions(+), 327 deletions(-) create mode 100644 tests/unit/json_schema.spec.ts diff --git a/package.json b/package.json index 76edea7..2b776c4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@release-it/conventional-changelog": "^10.0.1", "@swc/core": "1.10.7", "@types/dlv": "^1.1.5", + "@types/json-schema": "^7.0.15", "@types/node": "^22.15.2", "benchmark": "^2.1.4", "c8": "^10.1.3", diff --git a/src/schema/any/main.ts b/src/schema/any/main.ts index d171409..9ef2efd 100644 --- a/src/schema/any/main.ts +++ b/src/schema/any/main.ts @@ -8,8 +8,10 @@ */ import { BaseLiteralType } from '../base/literal.js' -import type { FieldOptions, Validation } from '../../types.js' -import { SUBTYPE } from '../../symbols.js' +import type { FieldOptions, ParserOptions, Validation } from '../../types.js' +import { PARSE, SUBTYPE } from '../../symbols.js' +import { RefsStore, LiteralNode } from '@vinejs/compiler/types' +import { JSONSchema7 } from 'json-schema' /** * VineAny represents a value that can be anything @@ -31,4 +33,36 @@ export class VineAny extends BaseLiteralType { clone(): this { return new VineAny(this.cloneOptions(), this.cloneValidations()) as this } + + protected compileJsonSchema(): JSONSchema7 { + const schema: JSONSchema7 = { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + { type: 'object' }, + ], + } + + for (const validation of this.validations) { + if (!validation.rule.jsonSchema) continue + validation.rule.jsonSchema(schema, validation.options) + } + + return schema + } + + /** + * Compiles the schema type to a compiler node + */ + [PARSE]( + propertyName: string, + refs: RefsStore, + options: ParserOptions + ): LiteralNode & { subtype: string } { + const schema = super[PARSE](propertyName, refs, options) + schema.json = this.compileJsonSchema() + return schema + } } diff --git a/src/schema/array/main.ts b/src/schema/array/main.ts index 006d876..42fb331 100644 --- a/src/schema/array/main.ts +++ b/src/schema/array/main.ts @@ -12,7 +12,13 @@ import { RefsStore, ArrayNode } from '@vinejs/compiler/types' import { BaseType } from '../base/main.js' import { ITYPE, OTYPE, COTYPE, PARSE, UNIQUE_NAME, IS_OF_TYPE } from '../../symbols.js' -import type { FieldOptions, ParserOptions, SchemaTypes, Validation } from '../../types.js' +import type { + CompilerNodes, + FieldOptions, + ParserOptions, + SchemaTypes, + Validation, +} from '../../types.js' import { compactRule, @@ -22,6 +28,7 @@ import { maxLengthRule, fixedLengthRule, } from './rules.js' +import { JSONSchema7 } from 'json-schema' /** * VineArray represents an array schema type in the validation @@ -114,10 +121,31 @@ export class VineArray extends BaseType< return new VineArray(this.#schema.clone(), this.cloneOptions(), this.cloneValidations()) as this } + /** + * Compiles JSON Schema. + */ + protected compileJsonSchema(node: CompilerNodes) { + const schema: JSONSchema7 = { + type: 'array', + } + + if ('json' in node) { + schema.items = node.json + } + + for (const validation of this.validations) { + if (!validation.rule.jsonSchema) continue + validation.rule.jsonSchema(schema, validation.options) + } + + return schema + } + /** * Compiles to array data type */ [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): ArrayNode { + const parsed = this.#schema[PARSE]('*', refs, options) return { type: 'array', fieldName: propertyName, @@ -125,9 +153,10 @@ export class VineArray extends BaseType< bail: this.options.bail, allowNull: this.options.allowNull, isOptional: this.options.isOptional, - each: this.#schema[PARSE]('*', refs, options), + each: parsed, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), + json: this.compileJsonSchema(parsed), } } } diff --git a/src/schema/array/rules.ts b/src/schema/array/rules.ts index ce8a671..b6a3816 100644 --- a/src/schema/array/rules.ts +++ b/src/schema/array/rules.ts @@ -14,97 +14,133 @@ import { createRule } from '../../vine/create_rule.js' /** * Enforce a minimum length on an array field */ -export const minLengthRule = createRule<{ min: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const minLengthRule = createRule<{ min: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * 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) + /** + * 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) + } + }, + { + json: (schema, options) => { + schema.minItems = options.min + }, } -}) +) /** * Enforce a maximum length on an array field */ -export const maxLengthRule = createRule<{ max: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const maxLengthRule = createRule<{ max: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * 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) + /** + * 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) + } + }, + { + json: (schema, options) => { + schema.maxItems = options.max + }, } -}) +) /** * Enforce a fixed length on an array field */ -export const fixedLengthRule = createRule<{ size: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const fixedLengthRule = createRule<{ size: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * Value will always be an array if the field is valid. - */ - if ((value as unknown[]).length !== options.size) { - field.report(messages['array.fixedLength'], 'array.fixedLength', field, options) + /** + * Value will always be an array if the field is valid. + */ + if ((value as unknown[]).length !== options.size) { + field.report(messages['array.fixedLength'], 'array.fixedLength', field, options) + } + }, + { + json: (schema, options) => { + schema.minItems = options.size + schema.maxItems = options.size + }, } -}) +) /** * Ensure the array is not empty */ -export const notEmptyRule = createRule((value, _, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const notEmptyRule = createRule( + (value, _, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * Value will always be an array if the field is valid. - */ - if ((value as unknown[]).length <= 0) { - field.report(messages.notEmpty, 'notEmpty', field) + /** + * Value will always be an array if the field is valid. + */ + if ((value as unknown[]).length <= 0) { + field.report(messages.notEmpty, 'notEmpty', field) + } + }, + { + json: (schema) => { + schema.minItems = 1 + }, } -}) +) /** * Ensure array elements are distinct/unique */ -export const distinctRule = createRule<{ fields?: string | string[] }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const distinctRule = createRule<{ fields?: string | string[] }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * Value will always be an array if the field is valid. - */ - if (!helpers.isDistinct(value as any[], options.fields)) { - field.report(messages.distinct, 'distinct', field, options) + /** + * Value will always be an array if the field is valid. + */ + if (!helpers.isDistinct(value as any[], options.fields)) { + field.report(messages.distinct, 'distinct', field, options) + } + }, + { + json: (schema) => { + schema.uniqueItems = true + }, } -}) +) /** * Removes empty strings, null and undefined values from the array diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index ef019ad..cb84dfa 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -27,6 +27,7 @@ import type { } from '../../types.js' import { requiredWhen } from './rules.js' import { helpers } from '../../vine/helpers.js' +import { JSONSchema7 } from 'json-schema' /** * Base schema type with only modifiers applicable on all the schema types. @@ -90,6 +91,53 @@ abstract class BaseModifiersType ): TransformModifier { return new TransformModifier(transformer, this) } + + meta(meta: JSONSchema7): MetaModifier { + return new MetaModifier(this, meta) + } +} + +class MetaModifier> extends BaseModifiersType< + Schema[typeof ITYPE], + Schema[typeof OTYPE], + Schema[typeof COTYPE] +> { + #parent: Schema + #meta: JSONSchema7 + + constructor(parent: Schema, meta: JSONSchema7) { + super() + this.#parent = parent + this.#meta = meta + } + + /** + * Creates a fresh instance of the underlying schema type + * and wraps it inside the nullable modifier + */ + clone(): this { + return new MetaModifier(this.#parent.clone(), this.#meta) as this + } + + /** + * 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 + + // TODO: We might want to deepmerge + output.json = { + ...output.json, + ...this.#meta, + } + + return output + } } /** @@ -127,6 +175,28 @@ export class NullableModifier< ): LiteralNode & { subtype: string } { const output = this.#parent[PARSE](propertyName, refs, options) output.allowNull = true + + // TODO: We might want to dedupe + if (output.json.anyOf) { + output.json.anyOf.push({ type: 'null' }) + return output + } + + if (output.json.type === undefined) { + output.json.type = 'null' + return output + } + + if (typeof output.json.type === 'string') { + output.json.type = [output.json.type, 'null'] + return output + } + + if (Array.isArray(output.json.type)) { + output.json.type.push('null') + return output + } + return output } } @@ -465,10 +535,24 @@ export abstract class BaseLiteralType extends Ba }), implicit: validation.rule.implicit, isAsync: validation.rule.isAsync, + json: validation.rule.jsonSchema, } }) } + /** + * Compiles JSON Schema. + */ + protected compileJsonSchema() { + const schema: JSONSchema7 = {} + for (const validation of this.validations) { + if (!validation.rule.jsonSchema) continue + validation.rule.jsonSchema(schema, validation.options) + } + + return schema + } + /** * Define a method to parse the input value. The method * is invoked before any validation and hence you must @@ -515,6 +599,7 @@ export abstract class BaseLiteralType extends Ba isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), + json: this.compileJsonSchema(), } } } diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index 4e54e42..96e2fb1 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -25,6 +25,7 @@ import type { import Macroable from '@poppinss/macroable' import { requiredWhen } from './rules.js' import { helpers } from '../../vine/helpers.js' +import { JSONSchema7 } from 'json-schema' /** * Base schema type with only modifiers applicable on all the schema types. @@ -74,6 +75,45 @@ export abstract class BaseModifiersType nullable(): NullableModifier { return new NullableModifier(this) } + + meta(meta: JSONSchema7): MetaModifier { + return new MetaModifier(this, meta) + } +} + +export class MetaModifier< + Schema extends BaseModifiersType, +> extends BaseModifiersType< + Schema[typeof ITYPE] | undefined | null, + Schema[typeof OTYPE], + Schema[typeof COTYPE] +> { + #parent: Schema + #meta: JSONSchema7 + + constructor(parent: Schema, meta: JSONSchema7) { + super() + this.#parent = parent + this.#meta = meta + } + + clone(): this { + return new MetaModifier(this.#parent.clone(), this.#meta) as this + } + + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): CompilerNodes { + const output = this.#parent[PARSE](propertyName, refs, options) + + // TODO: Remove this and we might want to deepmerge + if ('json' in output) { + output.json = { + ...output.json, + ...this.#meta, + } + } + + return output + } } /** @@ -107,6 +147,27 @@ export class NullableModifier< const output = this.#parent[PARSE](propertyName, refs, options) if (output.type !== 'union') { output.allowNull = true + + // TODO: We might want to dedupe + if (output.json.anyOf) { + output.json.anyOf.push({ type: 'null' }) + return output + } + + if (output.json.type === undefined) { + output.json.type = 'null' + return output + } + + if (typeof output.json.type === 'string') { + output.json.type = [output.json.type, 'null'] + return output + } + + if (Array.isArray(output.json.type)) { + output.json.type.push('null') + return output + } } return output diff --git a/src/schema/boolean/rules.ts b/src/schema/boolean/rules.ts index d5cb0b1..6b31091 100644 --- a/src/schema/boolean/rules.ts +++ b/src/schema/boolean/rules.ts @@ -14,12 +14,20 @@ import { createRule } from '../../vine/create_rule.js' /** * Validates the value to be a boolean */ -export const booleanRule = createRule<{ strict?: boolean }>((value, options, field) => { - const valueAsBoolean = options.strict === true ? value : helpers.asBoolean(value) - if (typeof valueAsBoolean !== 'boolean') { - field.report(messages.boolean, 'boolean', field) - return - } +export const booleanRule = createRule<{ strict?: boolean }>( + (value, options, field) => { + const valueAsBoolean = options.strict === true ? value : helpers.asBoolean(value) + if (typeof valueAsBoolean !== 'boolean') { + field.report(messages.boolean, 'boolean', field) + return + } - field.mutate(valueAsBoolean, field) -}) + field.mutate(valueAsBoolean, field) + }, + { + json: (schema) => { + // TODO: We might want to handle strictness with anyOf + schema.type = 'boolean' + }, + } +) diff --git a/src/schema/enum/rules.ts b/src/schema/enum/rules.ts index a88839e..1f9f251 100644 --- a/src/schema/enum/rules.ts +++ b/src/schema/enum/rules.ts @@ -17,14 +17,23 @@ import { FieldContext } from '@vinejs/compiler/types' */ export const enumRule = createRule<{ choices: readonly any[] | ((field: FieldContext) => readonly any[]) -}>((value, options, field) => { - const choices = typeof options.choices === 'function' ? options.choices(field) : options.choices +}>( + (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 + json: (schema, options) => { + if (typeof options.choices === 'function') return + schema.enum = options.choices as any[] + }, } -}) +) diff --git a/src/schema/number/rules.ts b/src/schema/number/rules.ts index 9f541ff..c962216 100644 --- a/src/schema/number/rules.ts +++ b/src/schema/number/rules.ts @@ -15,105 +15,150 @@ import { messages } from '../../defaults.js' * Enforce the value to be a number or a string representation * of a number */ -export const numberRule = createRule<{ strict?: boolean }>((value, options, field) => { - 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 - } - - field.mutate(valueAsNumber, field) -}) +export const numberRule = createRule<{ strict?: boolean }>( + (value, options, field) => { + 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 + } + + field.mutate(valueAsNumber, field) + }, + { + json: (schema) => { + schema.type = 'number' + }, + } +) /** * Enforce a minimum value on a number field */ -export const minRule = createRule<{ min: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if ((value as number) < options.min) { - field.report(messages.min, 'min', field, options) - } -}) +export const minRule = createRule<{ min: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if ((value as number) < options.min) { + field.report(messages.min, 'min', field, options) + } + }, + { + json: (schema, options) => { + schema.minimum = options.min + }, + } +) /** * Enforce a maximum value on a number field */ -export const maxRule = createRule<{ max: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if ((value as number) > options.max) { - field.report(messages.max, 'max', field, options) - } -}) +export const maxRule = createRule<{ max: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if ((value as number) > options.max) { + field.report(messages.max, 'max', field, options) + } + }, + { + json: (schema, options) => { + schema.maximum = options.max + }, + } +) /** * Enforce a range of values on a number field. */ -export const rangeRule = createRule<{ min: number; max: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if ((value as number) < options.min || (value as number) > options.max) { - field.report(messages.range, 'range', field, options) - } -}) +export const rangeRule = createRule<{ min: number; max: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if ((value as number) < options.min || (value as number) > options.max) { + field.report(messages.range, 'range', field, options) + } + }, + { + json: (schema, options) => { + schema.minimum = options.min + schema.maximum = options.max + }, + } +) /** * Enforce the value is a positive number */ -export const positiveRule = createRule((value, _, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if ((value as number) < 0) { - field.report(messages.positive, 'positive', field) - } -}) +export const positiveRule = createRule( + (value, _, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if ((value as number) < 0) { + field.report(messages.positive, 'positive', field) + } + }, + { + json: (schema) => { + schema.minimum = 0 + }, + } +) /** * Enforce the value is a negative number */ -export const negativeRule = createRule((value, _, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if ((value as number) >= 0) { - field.report(messages.negative, 'negative', field) - } -}) +export const negativeRule = createRule( + (value, _, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if ((value as number) >= 0) { + field.report(messages.negative, 'negative', field) + } + }, + { + json: (schema) => { + schema.exclusiveMaximum = 0 + }, + } +) /** * 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?] }>((value, options, field) => { /** * Skip if the field is not valid. @@ -135,31 +180,45 @@ export const decimalRule = createRule<{ range: [number, number?] }>((value, opti /** * Enforce the value to not have decimal places */ -export const withoutDecimalsRule = createRule((value, _, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if (!Number.isInteger(value)) { - field.report(messages.withoutDecimals, 'withoutDecimals', field) - } -}) +export const withoutDecimalsRule = createRule( + (value, _, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if (!Number.isInteger(value)) { + field.report(messages.withoutDecimals, 'withoutDecimals', field) + } + }, + { + json: (schema) => { + schema.type = 'integer' + }, + } +) /** * Enforce the value to be in a list of allowed values */ -export const inRule = createRule<{ values: number[] }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - - if (!options.values.includes(value as number)) { - field.report(messages['number.in'], 'in', field, options) - } -}) +export const inRule = createRule<{ values: number[] }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + + if (!options.values.includes(value as number)) { + field.report(messages['number.in'], 'in', field, options) + } + }, + { + json: (schema, options) => { + schema.enum = options.values + }, + } +) diff --git a/src/schema/object/main.ts b/src/schema/object/main.ts index 3d85e09..03786e3 100644 --- a/src/schema/object/main.ts +++ b/src/schema/object/main.ts @@ -8,13 +8,14 @@ */ import camelcase from 'camelcase' -import type { ObjectNode, RefsStore } from '@vinejs/compiler/types' +import type { CompilerNodes, ObjectNode, RefsStore } from '@vinejs/compiler/types' import { ObjectGroup } from './group.js' import { GroupConditional } from './conditional.js' import { BaseType, BaseModifiersType } from '../base/main.js' import { OTYPE, COTYPE, PARSE, UNIQUE_NAME, IS_OF_TYPE, ITYPE } from '../../symbols.js' import type { Validation, SchemaTypes, FieldOptions, ParserOptions } from '../../types.js' +import { JSONSchema7 } from 'json-schema' /** * Converts schema properties to camelCase @@ -212,10 +213,41 @@ export class VineObject< return new VineCamelCaseObject(this) } + /** + * Compiles JSON Schema. + */ + protected compileJsonSchema(nodes: CompilerNodes[]) { + const schema: JSONSchema7 & { properties: {}; required: [] } = { + type: 'object', + properties: {}, + required: [], + } + + for (const validation of this.validations) { + if (!validation.rule.jsonSchema) continue + validation.rule.jsonSchema(schema, validation.options) + } + + for (const node of nodes) { + if (node.type === 'literal') { + schema.properties[node.propertyName] = node.json + if (!node.isOptional) { + schema.required?.push(node.propertyName) + } + } + } + + return schema + } + /** * Compiles the schema type to a compiler node */ [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): ObjectNode { + const parsedProperties = Object.keys(this.#properties).map((property) => { + return this.#properties[property][PARSE](property, refs, options) + }) + return { type: 'object', fieldName: propertyName, @@ -226,12 +258,11 @@ export class VineObject< parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, allowUnknownProperties: this.#allowUnknownProperties, validations: this.compileValidations(refs), - properties: Object.keys(this.#properties).map((property) => { - return this.#properties[property][PARSE](property, refs, options) - }), + properties: parsedProperties, groups: this.#groups.map((group) => { return group[PARSE](refs, options) }), + json: this.compileJsonSchema(parsedProperties), } } } diff --git a/src/schema/record/main.ts b/src/schema/record/main.ts index e21b03b..957cb64 100644 --- a/src/schema/record/main.ts +++ b/src/schema/record/main.ts @@ -12,8 +12,15 @@ import { RefsStore, RecordNode } from '@vinejs/compiler/types' import { BaseType } from '../base/main.js' import { ITYPE, OTYPE, COTYPE, PARSE, UNIQUE_NAME, IS_OF_TYPE } from '../../symbols.js' -import type { FieldOptions, ParserOptions, SchemaTypes, Validation } from '../../types.js' +import type { + CompilerNodes, + FieldOptions, + ParserOptions, + SchemaTypes, + Validation, +} from '../../types.js' import { fixedLengthRule, maxLengthRule, minLengthRule, validateKeysRule } from './rules.js' +import { JSONSchema7 } from 'json-schema' /** * VineRecord represents an object of key-value pair in which @@ -94,10 +101,30 @@ export class VineRecord extends BaseType< ) as this } + protected compileJsonSchema(node: CompilerNodes) { + const schema: JSONSchema7 & {} = { + type: 'object', + additionalProperties: {}, + } + + // TODO: Remove condition + if ('json' in node) { + schema.additionalProperties = node.json + } + + for (const validation of this.validations) { + if (!validation.rule.jsonSchema) continue + validation.rule.jsonSchema(schema, validation.options) + } + + return schema + } + /** * Compiles to record data type */ [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): RecordNode { + const parsed = this.#schema[PARSE]('*', refs, options) return { type: 'record', fieldName: propertyName, @@ -105,9 +132,10 @@ export class VineRecord extends BaseType< bail: this.options.bail, allowNull: this.options.allowNull, isOptional: this.options.isOptional, - each: this.#schema[PARSE]('*', refs, options), + each: parsed, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), + json: this.compileJsonSchema(parsed), } } } diff --git a/src/schema/record/rules.ts b/src/schema/record/rules.ts index 488f7f2..4f0546e 100644 --- a/src/schema/record/rules.ts +++ b/src/schema/record/rules.ts @@ -14,59 +14,81 @@ import { createRule } from '../../vine/create_rule.js' /** * Enforce a minimum length on an object field */ -export const minLengthRule = createRule<{ min: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const minLengthRule = createRule<{ min: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * 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) + /** + * 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) + } + }, + { + json: (schema, options) => { + schema.minProperties = options.min + }, } -}) +) /** * Enforce a maximum length on an object field */ -export const maxLengthRule = createRule<{ max: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const maxLengthRule = createRule<{ max: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * 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) + /** + * 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) + } + }, + { + json: (schema, options) => { + schema.maxProperties = options.max + }, } -}) +) /** * Enforce a fixed length on an object field */ -export const fixedLengthRule = createRule<{ size: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const fixedLengthRule = createRule<{ size: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - /** - * Value will always be an object if the field is valid. - */ - if (Object.keys(value as Record).length !== options.size) { - field.report(messages['record.fixedLength'], 'record.fixedLength', field, options) + /** + * Value will always be an object if the field is valid. + */ + if (Object.keys(value as Record).length !== options.size) { + field.report(messages['record.fixedLength'], 'record.fixedLength', field, options) + } + }, + { + json: (schema, options) => { + schema.minProperties = options.size + schema.maxProperties = options.size + }, } -}) +) /** * Register a callback to validate the object keys diff --git a/src/schema/string/rules.ts b/src/schema/string/rules.ts index 9b5b701..ea7c287 100644 --- a/src/schema/string/rules.ts +++ b/src/schema/string/rules.ts @@ -32,24 +32,38 @@ import type { /** * Validates the value to be a string */ -export const stringRule = createRule((value, _, field) => { - if (typeof value !== 'string') { - field.report(messages.string, 'string', field) +export const stringRule = createRule( + (value, _, field) => { + if (typeof value !== 'string') { + field.report(messages.string, 'string', field) + } + }, + { + json: (schema) => { + schema.type = 'string' + }, } -}) +) /** * Validates the value to be a valid email address */ -export const emailRule = createRule((value, options, field) => { - if (!field.isValid) { - return - } +export const emailRule = createRule( + (value, options, field) => { + if (!field.isValid) { + return + } - if (!helpers.isEmail(value as string, options)) { - field.report(messages.email, 'email', field) + if (!helpers.isEmail(value as string, options)) { + field.report(messages.email, 'email', field) + } + }, + { + json: (schema) => { + schema.format = 'email' + }, } -}) +) /** * Validates the value to be a valid mobile number @@ -72,54 +86,82 @@ export const mobileRule = createRule< /** * Validates the value to be a valid IP address. */ -export const ipAddressRule = createRule<{ version: 4 | 6 } | undefined>((value, options, field) => { - if (!field.isValid) { - return - } +export const ipAddressRule = createRule<{ version: 4 | 6 } | undefined>( + (value, options, field) => { + if (!field.isValid) { + return + } - if (!helpers.isIP(value as string, options?.version)) { - field.report(messages.ipAddress, 'ipAddress', field) + if (!helpers.isIP(value as string, options?.version)) { + field.report(messages.ipAddress, 'ipAddress', field) + } + }, + { + json: (schema, options) => { + schema.format = options?.version === 6 ? 'ipv6' : 'ipv4' + }, } -}) +) /** * Validates the value against a regular expression */ -export const regexRule = createRule((value, expression, field) => { - if (!field.isValid) { - return - } +export const regexRule = createRule( + (value, expression, field) => { + if (!field.isValid) { + return + } - if (!expression.test(value as string)) { - field.report(messages.regex, 'regex', field) + if (!expression.test(value as string)) { + field.report(messages.regex, 'regex', field) + } + }, + { + json: (schema, options) => { + schema.pattern = options.source + }, } -}) +) /** * Validates the value to be a valid hex color code */ -export const hexCodeRule = createRule((value, _, field) => { - if (!field.isValid) { - return - } +export const hexCodeRule = createRule( + (value, _, field) => { + if (!field.isValid) { + return + } - if (!helpers.isHexColor(value as string)) { - field.report(messages.hexCode, 'hexCode', field) + if (!helpers.isHexColor(value as string)) { + field.report(messages.hexCode, 'hexCode', field) + } + }, + { + json: (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((value, options, field) => { - if (!field.isValid) { - return - } +export const urlRule = createRule( + (value, options, field) => { + if (!field.isValid) { + return + } - if (!helpers.isURL(value as string, options)) { - field.report(messages.url, 'url', field) + if (!helpers.isURL(value as string, options)) { + field.report(messages.url, 'url', field) + } + }, + { + json: (schema) => { + schema.format = 'uri' + }, } -}) +) /** * Validates the value to be an active URL @@ -137,29 +179,49 @@ export const activeUrlRule = createRule(async (value, _, field) => { /** * Validates the value to contain only letters */ -export const alphaRule = createRule((value, options, field) => { - if (!field.isValid) { - return - } - - let characterSet = 'a-zA-Z' - if (options) { - if (options.allowSpaces) { - characterSet += '\\s' +export const alphaRule = createRule( + (value, options, field) => { + if (!field.isValid) { + return } - if (options.allowDashes) { - characterSet += '-' + + let characterSet = 'a-zA-Z' + if (options) { + if (options.allowSpaces) { + characterSet += '\\s' + } + if (options.allowDashes) { + characterSet += '-' + } + if (options.allowUnderscores) { + characterSet += '_' + } } - if (options.allowUnderscores) { - characterSet += '_' + + const expression = new RegExp(`^[${characterSet}]+$`) + if (!expression.test(value as string)) { + field.report(messages.alpha, 'alpha', field) } - } + }, + { + json: (schema, options) => { + let characterSet = 'a-zA-Z' + if (options) { + if (options.allowSpaces) { + characterSet += '\\s' + } + if (options.allowDashes) { + characterSet += '-' + } + if (options.allowUnderscores) { + characterSet += '_' + } + } - const expression = new RegExp(`^[${characterSet}]+$`) - if (!expression.test(value as string)) { - field.report(messages.alpha, 'alpha', field) + schema.pattern = `^[${characterSet}]+$` + }, } -}) +) /** * Validates the value to contain only letters and numbers @@ -187,55 +249,95 @@ export const alphaNumericRule = createRule( if (!expression.test(value as string)) { field.report(messages.alphaNumeric, 'alphaNumeric', field) } + }, + { + json: (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 }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } - if ((value as string).length < options.min) { - field.report(messages.minLength, 'minLength', field, options) +export const minLengthRule = createRule<{ min: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } + if ((value as string).length < options.min) { + field.report(messages.minLength, 'minLength', field, options) + } + }, + { + json: (schema, options) => { + schema.minLength = options.min + }, } -}) +) /** * Enforce a maximum length on a string field */ -export const maxLengthRule = createRule<{ max: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const maxLengthRule = createRule<{ max: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - if ((value as string).length > options.max) { - field.report(messages.maxLength, 'maxLength', field, options) + if ((value as string).length > options.max) { + field.report(messages.maxLength, 'maxLength', field, options) + } + }, + { + json: (schema, options) => { + schema.maxLength = options.max + }, } -}) +) /** * Enforce a fixed length on a string field */ -export const fixedLengthRule = createRule<{ size: number }>((value, options, field) => { - /** - * Skip if the field is not valid. - */ - if (!field.isValid) { - return - } +export const fixedLengthRule = createRule<{ size: number }>( + (value, options, field) => { + /** + * Skip if the field is not valid. + */ + if (!field.isValid) { + return + } - if ((value as string).length !== options.size) { - field.report(messages.fixedLength, 'fixedLength', field, options) + if ((value as string).length !== options.size) { + field.report(messages.fixedLength, 'fixedLength', field, options) + } + }, + { + json: (schema, options) => { + schema.minLength = options.size + schema.maxLength = options.size + }, } -}) +) /** * Ensure the value ends with the pre-defined substring @@ -589,21 +691,33 @@ export const uuidRule = createRule<{ version?: (1 | 2 | 3 | 4 | 5)[] } | undefin field.report(messages.uuid, 'uuid', field, options) } } + }, + { + json: (schema) => { + schema.format = 'uuid' + }, } ) /** * Validates the value to be a valid ULID */ -export const ulidRule = createRule((value, _, field) => { - if (!field.isValid) { - return - } +export const ulidRule = createRule( + (value, _, field) => { + if (!field.isValid) { + return + } - if (!helpers.isULID(value as string)) { - field.report(messages.ulid, 'ulid', field) + if (!helpers.isULID(value as string)) { + field.report(messages.ulid, 'ulid', field) + } + }, + { + json: (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 816616d..33fc6e7 100644 --- a/src/schema/tuple/main.ts +++ b/src/schema/tuple/main.ts @@ -8,11 +8,12 @@ */ import camelcase from 'camelcase' -import { RefsStore, TupleNode } from '@vinejs/compiler/types' +import { CompilerNodes, RefsStore, 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 { JSONSchema7 } from 'json-schema' /** * VineTuple is an array with known length and may have different @@ -84,10 +85,36 @@ export class VineTuple< return cloned as this } + /** + * Compiles JSON Schema. + */ + protected compileJsonSchema(nodes: CompilerNodes[]) { + const schema: JSONSchema7 & { items: JSONSchema7[] } = { + type: 'array', + items: [], + additionalItems: false, + } + + for (const node of nodes) { + if ('json' in node) { + schema.items.push(node.json) + } + } + + for (const validation of this.validations) { + if (!validation.rule.jsonSchema) continue + validation.rule.jsonSchema(schema, validation.options) + } + + return schema + } + /** * Compiles to array data type */ [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): TupleNode { + const parsed = this.#schemas.map((schema, index) => schema[PARSE](String(index), refs, options)) + return { type: 'tuple', fieldName: propertyName, @@ -98,7 +125,8 @@ export class VineTuple< allowUnknownProperties: this.#allowUnknownProperties, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - properties: this.#schemas.map((schema, index) => schema[PARSE](String(index), refs, options)), + properties: parsed, + json: this.compileJsonSchema(parsed), } } } diff --git a/src/types.ts b/src/types.ts index ccd7161..5819171 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ import type { import type { helpers } from './vine/helpers.js' import type { ValidationError } from './errors/validation_error.js' import type { OTYPE, COTYPE, PARSE, VALIDATION, UNIQUE_NAME, IS_OF_TYPE, ITYPE } from './symbols.js' +import { JSONSchema7 } from 'json-schema' /** * Compiler nodes emitted by Vine @@ -157,6 +158,11 @@ export type Validator = ( field: FieldContext ) => any | Promise +export type JsonSchemaModifier = ( + schema: JSONSchema7, + options: Options +) => void + /** * A validation rule is a combination of a validator and * some metadata required at the time of compiling the @@ -168,6 +174,7 @@ export type ValidationRule = { validator: Validator isAsync: boolean implicit: boolean + jsonSchema?: JsonSchemaModifier } /** diff --git a/src/vine/create_rule.ts b/src/vine/create_rule.ts index dd80300..e8a4a46 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' /** * Returns args for the validation function. @@ -23,12 +23,14 @@ export function createRule( metaData?: { implicit?: boolean isAsync?: boolean + json?: JsonSchemaModifier } ) { const rule: ValidationRule = { validator, isAsync: metaData?.isAsync || validator.constructor.name === 'AsyncFunction', implicit: metaData?.implicit ?? false, + jsonSchema: metaData?.json, } return function (...options: GetArgs): Validation { diff --git a/src/vine/validator.ts b/src/vine/validator.ts index fdbf241..e979f70 100644 --- a/src/vine/validator.ts +++ b/src/vine/validator.ts @@ -20,6 +20,7 @@ import type { ValidationOptions, ErrorReporterContract, } from '../types.js' +import { JSONSchema7 } from 'json-schema' /** * Error messages to share with the compiler @@ -204,4 +205,14 @@ export class VineValidator< refs, } } + + toJSONSchema(): JSONSchema7 { + const schema = this.#compiled.schema.schema + + if ('json' in schema) { + return schema.json + } + + return {} + } } diff --git a/tests/unit/json_schema.spec.ts b/tests/unit/json_schema.spec.ts new file mode 100644 index 0000000..0934cb6 --- /dev/null +++ b/tests/unit/json_schema.spec.ts @@ -0,0 +1,291 @@ +import { test } from '@japa/runner' +import vine from '../../index.js' +import { SchemaTypes } from '../../src/types.js' +import { JSONSchema7 } from 'json-schema' + +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.compile(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.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + 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(), { type: 'null', enum: [1, 3] }], + [ + 'nullable with predifined type', + vine.enum(['foo', 'baz']).meta({ type: 'string' }).nullable(), + { type: ['string', 'null'], enum: ['foo', 'baz'] }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + test('vine.boolean() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['', vine.boolean(), { type: 'boolean' }], + ['nullable', vine.boolean().nullable(), { type: ['boolean', 'null'] }], + ['meta', vine.boolean().meta({ examples: [true] }), { type: 'boolean', examples: [true] }], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + 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.boolean().meta({ examples: [true] }), { type: 'boolean', examples: [true] }], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + 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, + }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + // TODO: We might want to add `additionalProperties: false` + test('vine.object() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + ['empty', vine.object({}), { type: 'object', properties: {}, required: [] }], + [ + 'properties', + vine.object({ hello: vine.string(), world: vine.number() }), + { + type: 'object', + properties: { + hello: { + type: 'string', + }, + world: { + type: 'number', + }, + }, + required: ['hello', 'world'], + }, + ], + [ + 'optional properties', + vine.object({ + foo: vine.number().optional(), + baz: vine.string(), + }), + { + type: 'object', + properties: { + foo: { + type: 'number', + }, + baz: { + type: 'string', + }, + }, + required: ['baz'], + }, + ], + [ + 'nullable', + vine.object({}).nullable(), + { type: ['object', 'null'], properties: {}, required: [] }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + 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' } }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + test('vine.tuple() - {0}') + .with<[string, SchemaTypes, JSONSchema7][]>([ + [ + '', + vine.tuple([vine.string(), vine.number()]), + { type: 'array', items: [{ type: 'string' }, { type: 'number' }], additionalItems: false }, + ], + // TODO: allowUnknownProperties() + [ + 'nullable', + vine.tuple([vine.string()]).nullable(), + { type: ['array', 'null'], items: [{ type: 'string' }], additionalItems: false }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) +}) From e89ee924837412968ee7baa9b57597faa8d9ebf0 Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Sat, 5 Jul 2025 23:11:21 +0200 Subject: [PATCH 2/7] fix: add base schema meta method --- src/schema/base/main.ts | 4 +++ tests/unit/json_schema.spec.ts | 52 +++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index 6321dd0..4063223 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -389,4 +389,8 @@ export abstract class BaseType nullable(): NullableModifier { return new NullableModifier(this) } + + meta(meta: JSONSchema7): MetaModifier { + return new MetaModifier(this, meta) + } } diff --git a/tests/unit/json_schema.spec.ts b/tests/unit/json_schema.spec.ts index 0934cb6..64ee20a 100644 --- a/tests/unit/json_schema.spec.ts +++ b/tests/unit/json_schema.spec.ts @@ -90,6 +90,14 @@ test.group('JsonSchema', () => { vine.enum(['foo', 'baz']).meta({ type: 'string' }).nullable(), { type: ['string', 'null'], enum: ['foo', 'baz'] }, ], + [ + 'meta', + vine.enum(Roles).meta({ default: Roles.ADMIN }), + { + enum: ['admin', 'moderator'], + default: 'admin', + }, + ], ]) .run(({ assert }, [_, schema, expected]) => { const validator = vine.compile(schema) @@ -136,7 +144,20 @@ test.group('JsonSchema', () => { ], }, ], - // ['meta', vine.boolean().meta({ examples: [true] }), { type: 'boolean', examples: [true] }], + [ + '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.compile(schema) @@ -175,6 +196,15 @@ test.group('JsonSchema', () => { 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.compile(schema) @@ -225,6 +255,11 @@ test.group('JsonSchema', () => { vine.object({}).nullable(), { type: ['object', 'null'], properties: {}, required: [] }, ], + [ + 'meta', + vine.object({}).meta({ description: 'Hello World!' }), + { type: 'object', description: 'Hello World!', properties: {}, required: [] }, + ], ]) .run(({ assert }, [_, schema, expected]) => { const validator = vine.compile(schema) @@ -264,6 +299,11 @@ test.group('JsonSchema', () => { vine.array(vine.string()).nullable(), { type: ['array', 'null'], items: { type: 'string' } }, ], + [ + 'meta', + vine.array(vine.boolean()).meta({ examples: [[true, false, false]] }), + { type: 'array', items: { type: 'boolean' }, examples: [[true, false, false]] }, + ], ]) .run(({ assert }, [_, schema, expected]) => { const validator = vine.compile(schema) @@ -283,6 +323,16 @@ test.group('JsonSchema', () => { vine.tuple([vine.string()]).nullable(), { type: ['array', 'null'], items: [{ type: 'string' }], additionalItems: false }, ], + [ + 'meta', + vine.tuple([vine.boolean(), vine.number()]).meta({ description: 'A tuple' }), + { + type: 'array', + items: [{ type: 'boolean' }, { type: 'number' }], + additionalItems: false, + description: 'A tuple', + }, + ], ]) .run(({ assert }, [_, schema, expected]) => { const validator = vine.compile(schema) From c01d132ac1b7cf39b4748c581e85fa612ca4e03d Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Sat, 5 Jul 2025 23:32:19 +0200 Subject: [PATCH 3/7] test: added integration tests with ajv --- package.json | 1 + tests/integration/json_schema/any.spec.ts | 27 +++++++++++ tests/integration/json_schema/string.spec.ts | 49 ++++++++++++++++++++ tests/integration/json_schema/tuple.spec.ts | 22 +++++++++ 4 files changed, 99 insertions(+) create mode 100644 tests/integration/json_schema/any.spec.ts create mode 100644 tests/integration/json_schema/string.spec.ts create mode 100644 tests/integration/json_schema/tuple.spec.ts diff --git a/package.json b/package.json index fa9c9bb..1b23564 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/dlv": "^1.1.5", "@types/json-schema": "^7.0.15", "@types/node": "^22.15.2", + "ajv": "^6.12.6", "benchmark": "^2.1.4", "c8": "^10.1.3", "del-cli": "^6.0.0", diff --git a/tests/integration/json_schema/any.spec.ts b/tests/integration/json_schema/any.spec.ts new file mode 100644 index 0000000..4704c05 --- /dev/null +++ b/tests/integration/json_schema/any.spec.ts @@ -0,0 +1,27 @@ +import { test } from '@japa/runner' +import vine from '../../../index.js' +import Ajv from 'ajv' +import { SchemaTypes } from '../../../src/types.js' + +const ajv = new Ajv() + +function validate(schema: SchemaTypes, value: any) { + const validator = ajv.compile(vine.compile(schema).toJSONSchema()) + return validator(value) +} + +test.group('JsonSchema', () => { + test('base', async ({ assert }) => { + const validator = vine.any() + + assert.isTrue(validate(validator, ['Hey', 5])) + assert.isTrue(validate(validator, { HO: true })) + assert.isFalse(validate(validator, null)) + }) + + test('nullable', async ({ assert }) => { + const validator = vine.any().nullable() + + assert.isTrue(validate(validator, null)) + }) +}) diff --git a/tests/integration/json_schema/string.spec.ts b/tests/integration/json_schema/string.spec.ts new file mode 100644 index 0000000..2f4de8b --- /dev/null +++ b/tests/integration/json_schema/string.spec.ts @@ -0,0 +1,49 @@ +import { test } from '@japa/runner' +import vine from '../../../index.js' +import Ajv from 'ajv' +import { SchemaTypes } from '../../../src/types.js' + +const ajv = new Ajv() + +function validate(schema: SchemaTypes, value: any) { + const validator = ajv.compile(vine.compile(schema).toJSONSchema()) + return validator(value) +} + +test.group('JsonSchema', () => { + test('base', async ({ assert }) => { + const validator = vine.string() + + assert.isTrue(validate(validator, 'Hello')) + assert.isFalse(validate(validator, 5)) + assert.isFalse(validate(validator, null)) + }) + + test('nullable', async ({ assert }) => { + const validator = vine.string().nullable() + assert.isTrue(validate(validator, 'Hello')) + assert.isTrue(validate(validator, null)) + assert.isFalse(validate(validator, undefined)) + }) + + test('minLength', async ({ assert }) => { + const validator = vine.string().minLength(5) + assert.isTrue(validate(validator, '12345')) + assert.isTrue(validate(validator, '12345678')) + assert.isFalse(validate(validator, '1234')) + }) + + test('maxLength', async ({ assert }) => { + const validator = vine.string().maxLength(5) + assert.isTrue(validate(validator, '1234')) + assert.isTrue(validate(validator, '12345')) + assert.isFalse(validate(validator, '123456')) + }) + + test('fixedLength', async ({ assert }) => { + const validator = vine.string().fixedLength(5) + assert.isTrue(validate(validator, '12345')) + assert.isFalse(validate(validator, '123456')) + assert.isFalse(validate(validator, '1234')) + }) +}) diff --git a/tests/integration/json_schema/tuple.spec.ts b/tests/integration/json_schema/tuple.spec.ts new file mode 100644 index 0000000..4f05727 --- /dev/null +++ b/tests/integration/json_schema/tuple.spec.ts @@ -0,0 +1,22 @@ +import { test } from '@japa/runner' +import vine from '../../../index.js' +import Ajv from 'ajv' +import { SchemaTypes } from '../../../src/types.js' + +const ajv = new Ajv() + +function validate(schema: SchemaTypes, value: any) { + const validator = ajv.compile(vine.compile(schema).toJSONSchema()) + return validator(value) +} + +test.group('JsonSchema', () => { + test('base', async ({ assert }) => { + const validator = vine.tuple([vine.string(), vine.number()]) + + assert.isTrue(validate(validator, ['Hey', 5])) + assert.isFalse(validate(validator, [5, 'Hey'])) + // TODO: Handle this + // assert.isFalse(validate(validator, [])) + }) +}) From a66cd153f37088e8b1ff9639aa81bca2c8d66c8e Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Mon, 7 Jul 2025 17:37:30 +0200 Subject: [PATCH 4/7] rename json schema properties and methods --- src/schema/any/main.ts | 10 ++++---- src/schema/array/main.ts | 10 ++++---- src/schema/array/rules.ts | 10 ++++---- src/schema/base/literal.ts | 48 ++++++++++++++++++++----------------- src/schema/base/main.ts | 38 +++++++++++++++++++---------- src/schema/boolean/rules.ts | 2 +- src/schema/enum/rules.ts | 2 +- src/schema/number/rules.ts | 16 ++++++------- src/schema/object/main.ts | 14 +++++------ src/schema/record/main.ts | 12 +++++----- src/schema/record/rules.ts | 6 ++--- src/schema/string/rules.ts | 26 ++++++++++---------- src/schema/tuple/main.ts | 10 ++++---- src/types.ts | 6 ++--- src/vine/create_rule.ts | 4 ++-- src/vine/validator.ts | 2 +- 16 files changed, 116 insertions(+), 100 deletions(-) diff --git a/src/schema/any/main.ts b/src/schema/any/main.ts index 292ce83..b623d3c 100644 --- a/src/schema/any/main.ts +++ b/src/schema/any/main.ts @@ -34,7 +34,7 @@ export class VineAny extends BaseLiteralType { return new VineAny(this.cloneOptions(), this.cloneValidations()) as this } - protected compileJsonSchema(): JSONSchema7 { + protected toJSONSchema(): JSONSchema7 { const schema: JSONSchema7 = { anyOf: [ { type: 'string' }, @@ -46,8 +46,8 @@ export class VineAny extends BaseLiteralType { } for (const validation of this.validations) { - if (!validation.rule.jsonSchema) continue - validation.rule.jsonSchema(schema, validation.options) + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) } return schema @@ -60,9 +60,9 @@ export class VineAny extends BaseLiteralType { propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; json: JSONSchema7 } { + ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { const schema = super[PARSE](propertyName, refs, options) - schema.json = this.compileJsonSchema() + schema.jsonSchema = this.toJSONSchema() return schema } } diff --git a/src/schema/array/main.ts b/src/schema/array/main.ts index 01be45c..01fbdd4 100644 --- a/src/schema/array/main.ts +++ b/src/schema/array/main.ts @@ -130,12 +130,12 @@ export class VineArray extends BaseType< } if ('json' in node) { - schema.items = node.json + schema.items = node.jsonSchema } for (const validation of this.validations) { - if (!validation.rule.jsonSchema) continue - validation.rule.jsonSchema(schema, validation.options) + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) } return schema @@ -148,7 +148,7 @@ export class VineArray extends BaseType< propertyName: string, refs: RefsStore, options: ParserOptions - ): ArrayNode & { json: JSONSchema7 } { + ): ArrayNode & { jsonSchema: JSONSchema7 } { const parsed = this.#schema[PARSE]('*', refs, options) return { type: 'array', @@ -160,7 +160,7 @@ export class VineArray extends BaseType< each: parsed, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - json: this.compileJsonSchema(parsed), + jsonSchema: this.compileJsonSchema(parsed), } } } diff --git a/src/schema/array/rules.ts b/src/schema/array/rules.ts index 316dcce..e5b2629 100644 --- a/src/schema/array/rules.ts +++ b/src/schema/array/rules.ts @@ -24,7 +24,7 @@ export const minLengthRule = createRule<{ min: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minItems = options.min }, } @@ -43,7 +43,7 @@ export const maxLengthRule = createRule<{ max: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.maxItems = options.max }, } @@ -62,7 +62,7 @@ export const fixedLengthRule = createRule<{ size: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minItems = options.size schema.maxItems = options.size }, @@ -82,7 +82,7 @@ export const notEmptyRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.minItems = 1 }, } @@ -101,7 +101,7 @@ export const distinctRule = createRule<{ fields?: string | string[] }>( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.uniqueItems = true }, } diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index e336ad3..e4c237b 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -91,28 +91,28 @@ export class NullableModifier /** * Compiles JSON Schema. */ - protected compileJsonSchema() { + protected toJSONSchema() { const schema: JSONSchema7 = {} - if (this.dataTypeValidator?.rule.jsonSchema) { - this.dataTypeValidator.rule.jsonSchema(schema, this.dataTypeValidator.options) + if (this.dataTypeValidator?.rule.toJSONSchema) { + this.dataTypeValidator.rule.toJSONSchema(schema, this.dataTypeValidator.options) } for (const validation of this.validations) { - if (!validation.rule.jsonSchema) continue - validation.rule.jsonSchema(schema, validation.options) + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) } return schema @@ -545,7 +545,11 @@ export abstract class BaseLiteralType return new NullableModifier(this) } - meta(meta: JSONSchema7): MetaModifier { + /** + * 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) } @@ -566,7 +570,7 @@ export abstract class BaseLiteralType propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; json: JSONSchema7 } { + ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { return { type: 'literal', subtype: this[SUBTYPE], @@ -580,7 +584,7 @@ export abstract class BaseLiteralType isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - json: this.compileJsonSchema(), + jsonSchema: this.toJSONSchema(), } } } diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index 4063223..b7cd943 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -60,7 +60,11 @@ export class NullableModifier> return new OptionalModifier(this) } - meta(meta: JSONSchema7): MetaModifier { + /** + * 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) } @@ -81,23 +85,23 @@ export class NullableModifier> output.allowNull = true // TODO: We might want to dedupe - if (output.json.anyOf) { - output.json.anyOf.push({ type: 'null' }) + if (output.jsonSchema.anyOf) { + output.jsonSchema.anyOf.push({ type: 'null' }) return output } - if (output.json.type === undefined) { - output.json.type = 'null' + if (output.jsonSchema.type === undefined) { + output.jsonSchema.type = 'null' return output } - if (typeof output.json.type === 'string') { - output.json.type = [output.json.type, 'null'] + if (typeof output.jsonSchema.type === 'string') { + output.jsonSchema.type = [output.jsonSchema.type, 'null'] return output } - if (Array.isArray(output.json.type)) { - output.json.type.push('null') + if (Array.isArray(output.jsonSchema.type)) { + output.jsonSchema.type.push('null') return output } } @@ -122,9 +126,9 @@ export class MetaModifier> declare [COTYPE]: Schema[typeof COTYPE] #parent: Schema - #meta: JSONSchema7 + #meta: JSONSchema7 | Object - constructor(parent: Schema, meta: JSONSchema7) { + constructor(parent: Schema, meta: JSONSchema7 | Object) { this.#parent = parent this.#meta = meta } @@ -136,8 +140,8 @@ export class MetaModifier> [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): CompilerNodes { const output = this.#parent[PARSE](propertyName, refs, options) - output.json = { - ...output.json, + output.jsonSchema = { + ...output.jsonSchema, ...this.#meta, } @@ -224,6 +228,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. */ diff --git a/src/schema/boolean/rules.ts b/src/schema/boolean/rules.ts index b5753d1..030b546 100644 --- a/src/schema/boolean/rules.ts +++ b/src/schema/boolean/rules.ts @@ -25,7 +25,7 @@ export const booleanRule = createRule<{ strict?: boolean }>( field.mutate(valueAsBoolean, field) }, { - json: (schema) => { + toJSONSchema: (schema) => { // TODO: We might want to handle strictness with anyOf schema.type = 'boolean' }, diff --git a/src/schema/enum/rules.ts b/src/schema/enum/rules.ts index 82d5c1e..db756fe 100644 --- a/src/schema/enum/rules.ts +++ b/src/schema/enum/rules.ts @@ -31,7 +31,7 @@ export const enumRule = createRule<{ }, { // TODO: We might want to handle this differently - json: (schema, options) => { + toJSONSchema: (schema, options) => { if (typeof options.choices === 'function') return schema.enum = options.choices as any[] }, diff --git a/src/schema/number/rules.ts b/src/schema/number/rules.ts index 23bea99..b5eb07b 100644 --- a/src/schema/number/rules.ts +++ b/src/schema/number/rules.ts @@ -37,7 +37,7 @@ export const numberRule = createRule<{ strict?: boolean }>( return true }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.type = 'number' }, } @@ -53,7 +53,7 @@ export const minRule = createRule<{ min: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minimum = options.min }, } @@ -69,7 +69,7 @@ export const maxRule = createRule<{ max: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.maximum = options.max }, } @@ -85,7 +85,7 @@ export const rangeRule = createRule<{ min: number; max: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minimum = options.min schema.maximum = options.max }, @@ -102,7 +102,7 @@ export const positiveRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.minimum = 0 }, } @@ -118,7 +118,7 @@ export const negativeRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.exclusiveMaximum = 0 }, } @@ -152,7 +152,7 @@ export const withoutDecimalsRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.type = 'integer' }, } @@ -168,7 +168,7 @@ export const inRule = createRule<{ values: number[] }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.enum = options.values }, } diff --git a/src/schema/object/main.ts b/src/schema/object/main.ts index ba86568..bc2797f 100644 --- a/src/schema/object/main.ts +++ b/src/schema/object/main.ts @@ -65,7 +65,7 @@ export class VineCamelCaseObject> propertyName: string, refs: RefsStore, options: ParserOptions - ): ObjectNode & { json: JSONSchema7 } { + ): ObjectNode & { jsonSchema: JSONSchema7 } { options.toCamelCase = true return this.#schema[PARSE](propertyName, refs, options) } @@ -228,7 +228,7 @@ export class VineObject< /** * Compiles JSON Schema. */ - protected compileJsonSchema(nodes: CompilerNodes[]) { + protected toJSONSchema(nodes: CompilerNodes[]) { const schema: JSONSchema7 & { properties: {}; required: [] } = { type: 'object', properties: {}, @@ -236,12 +236,12 @@ export class VineObject< } for (const validation of this.validations) { - if (!validation.rule.jsonSchema) continue - validation.rule.jsonSchema(schema, validation.options) + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) } for (const node of nodes) { - schema.properties[node.propertyName] = node.json + schema.properties[node.propertyName] = node.jsonSchema if (!('isOptional' in node) || !node.isOptional) { schema.required.push(node.propertyName) @@ -258,7 +258,7 @@ export class VineObject< propertyName: string, refs: RefsStore, options: ParserOptions - ): ObjectNode & { json: JSONSchema7 } { + ): ObjectNode & { jsonSchema: JSONSchema7 } { const parsedProperties = Object.keys(this.#properties).map((property) => { return this.#properties[property][PARSE](property, refs, options) }) @@ -277,7 +277,7 @@ export class VineObject< groups: this.#groups.map((group) => { return group[PARSE](refs, options) }), - json: this.compileJsonSchema(parsedProperties), + jsonSchema: this.toJSONSchema(parsedProperties), } } } diff --git a/src/schema/record/main.ts b/src/schema/record/main.ts index 1e10431..b2a9831 100644 --- a/src/schema/record/main.ts +++ b/src/schema/record/main.ts @@ -101,7 +101,7 @@ export class VineRecord extends BaseType< ) as this } - protected compileJsonSchema(node: CompilerNodes) { + protected toJSONSchema(node: CompilerNodes) { const schema: JSONSchema7 & {} = { type: 'object', additionalProperties: {}, @@ -109,12 +109,12 @@ export class VineRecord extends BaseType< // TODO: Remove condition if ('json' in node) { - schema.additionalProperties = node.json + schema.additionalProperties = node.jsonSchema } for (const validation of this.validations) { - if (!validation.rule.jsonSchema) continue - validation.rule.jsonSchema(schema, validation.options) + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) } return schema @@ -127,7 +127,7 @@ export class VineRecord extends BaseType< propertyName: string, refs: RefsStore, options: ParserOptions - ): RecordNode & { json: JSONSchema7 } { + ): RecordNode & { jsonSchema: JSONSchema7 } { const parsed = this.#schema[PARSE]('*', refs, options) return { type: 'record', @@ -139,7 +139,7 @@ export class VineRecord extends BaseType< each: parsed, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - json: this.compileJsonSchema(parsed), + jsonSchema: this.toJSONSchema(parsed), } } } diff --git a/src/schema/record/rules.ts b/src/schema/record/rules.ts index ada3173..0deec43 100644 --- a/src/schema/record/rules.ts +++ b/src/schema/record/rules.ts @@ -25,7 +25,7 @@ export const minLengthRule = createRule<{ min: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minProperties = options.min }, } @@ -44,7 +44,7 @@ export const maxLengthRule = createRule<{ max: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.maxProperties = options.max }, } @@ -63,7 +63,7 @@ export const fixedLengthRule = createRule<{ size: number }>( } }, { - json: (schema, 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 27f5402..708e4e8 100644 --- a/src/schema/string/rules.ts +++ b/src/schema/string/rules.ts @@ -46,7 +46,7 @@ export const stringRule = createRule( return false }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.type = 'string' }, } @@ -62,7 +62,7 @@ export const emailRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.format = 'email' }, } @@ -92,7 +92,7 @@ export const ipAddressRule = createRule<{ version: 4 | 6 } | undefined>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.format = options?.version === 6 ? 'ipv6' : 'ipv4' }, } @@ -108,7 +108,7 @@ export const regexRule = createRule( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.pattern = options.source }, } @@ -124,7 +124,7 @@ export const hexCodeRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.pattern = '^#?([0-9a-f]{6}|[0-9a-f]{3}|[0-9a-f]{8})$' }, } @@ -141,7 +141,7 @@ export const urlRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.format = 'uri' }, } @@ -180,7 +180,7 @@ export const alphaRule = createRule( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { let characterSet = 'a-zA-Z' if (options) { if (options.allowSpaces) { @@ -223,7 +223,7 @@ export const alphaNumericRule = createRule( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { let characterSet = 'a-zA-Z0-9' if (options) { if (options.allowSpaces) { @@ -252,7 +252,7 @@ export const minLengthRule = createRule<{ min: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minLength = options.min }, } @@ -268,7 +268,7 @@ export const maxLengthRule = createRule<{ max: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.maxLength = options.max }, } @@ -284,7 +284,7 @@ export const fixedLengthRule = createRule<{ size: number }>( } }, { - json: (schema, options) => { + toJSONSchema: (schema, options) => { schema.minLength = options.size schema.maxLength = options.size }, @@ -494,7 +494,7 @@ export const uuidRule = createRule<{ version?: (1 | 2 | 3 | 4 | 5)[] } | undefin } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.format = 'uuid' }, } @@ -510,7 +510,7 @@ export const ulidRule = createRule( } }, { - json: (schema) => { + toJSONSchema: (schema) => { schema.pattern = '^[0-7][0-9A-HJKMNP-TV-Z]{25}$' }, } diff --git a/src/schema/tuple/main.ts b/src/schema/tuple/main.ts index 6d3b15b..7c4018e 100644 --- a/src/schema/tuple/main.ts +++ b/src/schema/tuple/main.ts @@ -103,13 +103,13 @@ export class VineTuple< for (const node of nodes) { if ('json' in node) { - schema.items.push(node.json) + schema.items.push(node.jsonSchema) } } for (const validation of this.validations) { - if (!validation.rule.jsonSchema) continue - validation.rule.jsonSchema(schema, validation.options) + if (!validation.rule.toJSONSchema) continue + validation.rule.toJSONSchema(schema, validation.options) } return schema @@ -122,7 +122,7 @@ export class VineTuple< propertyName: string, refs: RefsStore, options: ParserOptions - ): TupleNode & { json: JSONSchema7 } { + ): TupleNode & { jsonSchema: JSONSchema7 } { const parsed = this.#schemas.map((schema, index) => schema[PARSE](String(index), refs, options)) return { @@ -136,7 +136,7 @@ export class VineTuple< parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), properties: parsed, - json: this.compileJsonSchema(parsed), + jsonSchema: this.compileJsonSchema(parsed), } } } diff --git a/src/types.ts b/src/types.ts index 04f9bc3..9ab4c58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,7 +44,7 @@ export type CompilerNodes = ( | UnionNode | RecordNode | TupleNode -) & { json: JSONSchema7 } +) & { jsonSchema: JSONSchema7 } /** * Options accepted by the mobile number validation @@ -160,7 +160,7 @@ export interface ConstructableLiteralSchema { propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; json: JSONSchema7 } + ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } clone(): this /** @@ -213,7 +213,7 @@ export type ValidationRule = { name: string isAsync: boolean implicit: boolean - jsonSchema?: JsonSchemaModifier + toJSONSchema?: JsonSchemaModifier } /** diff --git a/src/vine/create_rule.ts b/src/vine/create_rule.ts index 2c01311..c61cafc 100644 --- a/src/vine/create_rule.ts +++ b/src/vine/create_rule.ts @@ -24,7 +24,7 @@ export function createRule( name?: string implicit?: boolean isAsync?: boolean - json?: JsonSchemaModifier + toJSONSchema?: JsonSchemaModifier } ) { const rule: ValidationRule = { @@ -32,7 +32,7 @@ export function createRule( name: metaData?.name ?? validator.name, isAsync: metaData?.isAsync || validator.constructor.name === 'AsyncFunction', implicit: metaData?.implicit ?? false, - jsonSchema: metaData?.json, + toJSONSchema: metaData?.toJSONSchema, } return function (...options: GetArgs): Validation { diff --git a/src/vine/validator.ts b/src/vine/validator.ts index eed20ab..c6cb0c9 100644 --- a/src/vine/validator.ts +++ b/src/vine/validator.ts @@ -211,7 +211,7 @@ export class VineValidator< 'toJSONSchema'(): JSONSchema7 { const schema = this.#compiled.schema.schema as CompilerNodes - return schema.json + return schema.jsonSchema } readonly '~standard': StandardSchemaV1.Props = { From f6a579bcf2eae6358b4396db11760c0d1ef9a590 Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Mon, 7 Jul 2025 18:25:55 +0200 Subject: [PATCH 5/7] add conditional groups support --- src/schema/any/main.ts | 3 + src/schema/array/main.ts | 6 +- src/schema/base/literal.ts | 13 +++- src/schema/base/main.ts | 8 +++ src/schema/literal/main.ts | 27 +++++++ src/schema/null/main.ts | 6 +- src/schema/object/conditional.ts | 38 ++++++++-- src/schema/object/group.ts | 21 +++++- src/schema/object/main.ts | 25 +++++-- src/schema/optional/main.ts | 4 +- src/schema/record/main.ts | 3 + src/schema/tuple/main.ts | 6 +- src/schema/union/conditional.ts | 8 ++- src/schema/union/main.ts | 35 +++++++-- src/schema/union_of_types/main.ts | 36 +++++++--- tests/unit/json_schema.spec.ts | 116 ++++++++++++++++++++++++++++++ 16 files changed, 314 insertions(+), 41 deletions(-) diff --git a/src/schema/any/main.ts b/src/schema/any/main.ts index b623d3c..fce643d 100644 --- a/src/schema/any/main.ts +++ b/src/schema/any/main.ts @@ -34,6 +34,9 @@ export class VineAny extends BaseLiteralType { return new VineAny(this.cloneOptions(), this.cloneValidations()) as this } + /** + * Transforms into JSONSchema. + */ protected toJSONSchema(): JSONSchema7 { const schema: JSONSchema7 = { anyOf: [ diff --git a/src/schema/array/main.ts b/src/schema/array/main.ts index 01fbdd4..c9252f5 100644 --- a/src/schema/array/main.ts +++ b/src/schema/array/main.ts @@ -122,9 +122,9 @@ export class VineArray extends BaseType< } /** - * Compiles JSON Schema. + * Transforms into JSONSchema. */ - protected compileJsonSchema(node: CompilerNodes) { + protected toJSONSchema(node: CompilerNodes): JSONSchema7 { const schema: JSONSchema7 = { type: 'array', } @@ -160,7 +160,7 @@ export class VineArray extends BaseType< each: parsed, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - jsonSchema: this.compileJsonSchema(parsed), + jsonSchema: this.toJSONSchema(parsed), } } } diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index e4c237b..43fc72a 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -80,7 +80,11 @@ export class NullableModifier { + /** + * 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) } @@ -120,6 +124,9 @@ export class NullableModifier> implements ConstructableLiteralSchema @@ -165,7 +172,7 @@ export class MetaModifier } /** - * Compiles JSON Schema. + * Transforms into JSONSchema. */ protected toJSONSchema() { const schema: JSONSchema7 = {} diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index b7cd943..21d6d30 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -133,6 +133,10 @@ export class MetaModifier> 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 } @@ -402,6 +406,10 @@ export abstract class BaseType 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/literal/main.ts b/src/schema/literal/main.ts index abf955b..be8937e 100644 --- a/src/schema/literal/main.ts +++ b/src/schema/literal/main.ts @@ -12,6 +12,7 @@ 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 { JSONSchema7 } from 'json-schema' /** * VineLiteral represents a type that matches an exact value @@ -57,4 +58,30 @@ export class VineLiteral extends BaseLiteralType { propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string } { + ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { return { type: 'literal', subtype: this[SUBTYPE], @@ -94,6 +95,9 @@ export class VineNull implements ConstructableSchema { isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: [], + jsonSchema: { + type: 'null', + }, } } } diff --git a/src/schema/object/conditional.ts b/src/schema/object/conditional.ts index abf4d83..7b1ed61 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 { CompilerNodes, ParserOptions, SchemaTypes } from '../../types.js' +import { JSONSchema7 } from 'json-schema' /** * Group conditional represents a sub-set of object wrapped @@ -41,19 +42,46 @@ export class GroupConditional< this.#conditional = conditional } + /** + * Transforms into JSONSchema. + */ + protected toJSONSchema(nodes: CompilerNodes[]) { + const schema: JSONSchema7 & { properties: {}; required: [] } = { + type: 'object', + properties: {}, + required: [], + } + + for (const node of nodes) { + schema.properties[node.propertyName] = node.jsonSchema + + if (!('isOptional' in node) || !node.isOptional) { + schema.required.push(node.propertyName) + } + } + + return schema + } + /** * Compiles to a union conditional */ - [PARSE](refs: RefsStore, options: ParserOptions): ObjectGroupNode['conditions'][number] { + [PARSE]( + refs: RefsStore, + options: ParserOptions + ): ObjectGroupNode['conditions'][number] & { jsonSchema: JSONSchema7 } { + const parsedProperties = Object.keys(this.#properties).map((property) => { + return this.#properties[property][PARSE](property, refs, options) + }) + return { schema: { type: 'sub_object', - properties: Object.keys(this.#properties).map((property) => { - return this.#properties[property][PARSE](property, refs, options) - }), + properties: parsedProperties, groups: [], // Compiler allows nested groups, but we are not implementing it }, conditionalFnRefId: refs.trackConditional(this.#conditional), + jsonSchema: this.toJSONSchema(parsedProperties), } } } diff --git a/src/schema/object/group.ts b/src/schema/object/group.ts index bd69a44..c5a890a 100644 --- a/src/schema/object/group.ts +++ b/src/schema/object/group.ts @@ -13,6 +13,7 @@ import { messages } from '../../defaults.js' import { GroupConditional } from './conditional.js' import { ITYPE, OTYPE, COTYPE, PARSE } from '../../symbols.js' import type { ParserOptions, UnionNoMatchCallback } from '../../types.js' +import { JSONSchema7 } from 'json-schema' /** * Object group represents a group with multiple conditionals, where each @@ -33,6 +34,17 @@ export class ObjectGroup group.jsonSchema), + } + } + /** * Clones the ObjectGroup schema type. */ @@ -54,11 +66,16 @@ export class ObjectGroup + conditional[PARSE](refs, options) + ) + return { type: 'group', elseConditionalFnRefId: refs.trackConditional(this.#otherwiseCallback), - conditions: this.#conditionals.map((conditional) => conditional[PARSE](refs, options)), + conditions: parsedConditions, + jsonSchema: this.toJSONSchema(parsedConditions), } } } diff --git a/src/schema/object/main.ts b/src/schema/object/main.ts index bc2797f..0c3cba3 100644 --- a/src/schema/object/main.ts +++ b/src/schema/object/main.ts @@ -21,7 +21,7 @@ import type { CompilerNodes, } from '../../types.js' import { JSONSchema7 } from 'json-schema' -import { ObjectNode, RefsStore } from '@vinejs/compiler/types' +import { ObjectGroupNode, ObjectNode, RefsStore } from '@vinejs/compiler/types' /** * Converts schema properties to camelCase @@ -226,9 +226,12 @@ export class VineObject< } /** - * Compiles JSON Schema. + * Transforms into JSONSchema. */ - protected toJSONSchema(nodes: CompilerNodes[]) { + protected toJSONSchema( + nodes: CompilerNodes[], + groups: (ObjectGroupNode & { jsonSchema: JSONSchema7 })[] + ): JSONSchema7 { const schema: JSONSchema7 & { properties: {}; required: [] } = { type: 'object', properties: {}, @@ -248,6 +251,12 @@ export class VineObject< } } + if (groups.length > 0) { + return { + anyOf: [schema, ...groups.map((group) => group.jsonSchema)], + } + } + return schema } @@ -263,6 +272,10 @@ export class VineObject< return this.#properties[property][PARSE](property, refs, options) }) + const parsedGroups = this.#groups.map((group) => { + return group[PARSE](refs, options) + }) + return { type: 'object', fieldName: propertyName, @@ -274,10 +287,8 @@ export class VineObject< allowUnknownProperties: this.#allowUnknownProperties, validations: this.compileValidations(refs), properties: parsedProperties, - groups: this.#groups.map((group) => { - return group[PARSE](refs, options) - }), - jsonSchema: this.toJSONSchema(parsedProperties), + groups: parsedGroups, + jsonSchema: this.toJSONSchema(parsedProperties, parsedGroups), } } } diff --git a/src/schema/optional/main.ts b/src/schema/optional/main.ts index 3f8e0b3..1c3ea44 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 { JSONSchema7 } from 'json-schema' /** * Specify an optional value inside a union. @@ -177,7 +178,7 @@ export class VineOptional propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string } { + ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { return { type: 'literal', subtype: this[SUBTYPE], @@ -188,6 +189,7 @@ export class VineOptional isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), + jsonSchema: {}, } } } diff --git a/src/schema/record/main.ts b/src/schema/record/main.ts index b2a9831..baedcd2 100644 --- a/src/schema/record/main.ts +++ b/src/schema/record/main.ts @@ -101,6 +101,9 @@ export class VineRecord extends BaseType< ) as this } + /** + * Transforms into JSONSchema. + */ protected toJSONSchema(node: CompilerNodes) { const schema: JSONSchema7 & {} = { type: 'object', diff --git a/src/schema/tuple/main.ts b/src/schema/tuple/main.ts index 7c4018e..e742c9e 100644 --- a/src/schema/tuple/main.ts +++ b/src/schema/tuple/main.ts @@ -92,9 +92,9 @@ export class VineTuple< } /** - * Compiles JSON Schema. + * Transforms into JSONSchema. */ - protected compileJsonSchema(nodes: CompilerNodes[]) { + protected toJSONSchema(nodes: CompilerNodes[]) { const schema: JSONSchema7 & { items: JSONSchema7[] } = { type: 'array', items: [], @@ -136,7 +136,7 @@ export class VineTuple< parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), properties: parsed, - jsonSchema: this.compileJsonSchema(parsed), + jsonSchema: this.toJSONSchema(parsed), } } } diff --git a/src/schema/union/conditional.ts b/src/schema/union/conditional.ts index 128614c..e8afa0c 100644 --- a/src/schema/union/conditional.ts +++ b/src/schema/union/conditional.ts @@ -11,6 +11,7 @@ import { ConditionalFn, RefsStore, UnionNode } from '@vinejs/compiler/types' import { ITYPE, OTYPE, COTYPE, PARSE } from '../../symbols.js' import type { ParserOptions, SchemaTypes } from '../../types.js' +import { JSONSchema7 } from 'json-schema' /** * Represents a union conditional type. A conditional is a predicate @@ -43,10 +44,13 @@ export class UnionConditional { propertyName: string, refs: RefsStore, options: ParserOptions - ): UnionNode['conditions'][number] { + ): UnionNode['conditions'][number] & { jsonSchema: JSONSchema7 } { + const parsedSchema = this.#schema[PARSE](propertyName, refs, options) + return { conditionalFnRefId: refs.trackConditional(this.#conditional), - schema: this.#schema[PARSE](propertyName, refs, options), + schema: parsedSchema, + jsonSchema: parsedSchema.jsonSchema, } } } diff --git a/src/schema/union/main.ts b/src/schema/union/main.ts index 51f2daa..613e832 100644 --- a/src/schema/union/main.ts +++ b/src/schema/union/main.ts @@ -8,7 +8,7 @@ */ import camelcase from 'camelcase' -import { RefsStore, UnionNode } from '@vinejs/compiler/types' +import { CompilerNodes, RefsStore, UnionNode } from '@vinejs/compiler/types' import { messages } from '../../defaults.js' import { UnionConditional } from './conditional.js' @@ -18,9 +18,11 @@ import type { ParserOptions, ConstructableSchema, UnionNoMatchCallback, + RefIdentifier, } from '../../types.js' import { VineOptional } from '../optional/main.js' import { VineNull } from '../null/main.js' +import { JSONSchema7 } from 'json-schema' /** * Vine union represents a union data type. A union is a collection @@ -83,6 +85,22 @@ export class VineUnion> return this } + /** + * Transforms into JSONSchema. + */ + protected toJSONSchema( + conditions: ({ + conditionalFnRefId: RefIdentifier + schema: CompilerNodes + } & { + jsonSchema: JSONSchema7 + })[] + ): JSONSchema7 { + return { + anyOf: conditions.map((condition) => condition.jsonSchema), + } + } + /** * Clones the VineUnion schema type. */ @@ -96,15 +114,22 @@ export class VineUnion> /** * Compiles to a union */ - [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): UnionNode { + [PARSE]( + propertyName: string, + refs: RefsStore, + options: ParserOptions + ): UnionNode & { jsonSchema: JSONSchema7 } { + const parsedConditions = this.#conditionals.map((conditional) => + conditional[PARSE](propertyName, refs, options) + ) + return { type: 'union', fieldName: propertyName, propertyName: options.toCamelCase ? camelcase(propertyName) : propertyName, elseConditionalFnRefId: refs.trackConditional(this.#otherwiseCallback), - conditions: this.#conditionals.map((conditional) => - conditional[PARSE](propertyName, refs, options) - ), + conditions: parsedConditions, + jsonSchema: this.toJSONSchema(parsedConditions), } } } diff --git a/src/schema/union_of_types/main.ts b/src/schema/union_of_types/main.ts index a93dfcc..ebcdb7f 100644 --- a/src/schema/union_of_types/main.ts +++ b/src/schema/union_of_types/main.ts @@ -17,9 +17,11 @@ import type { ParserOptions, ConstructableSchema, UnionNoMatchCallback, + CompilerNodes, } from '../../types.js' import { VineOptional } from '../optional/main.js' import { VineNull } from '../null/main.js' +import { JSONSchema7 } from 'json-schema' /** * Vine union represents a union data type. A union is a collection @@ -82,23 +84,39 @@ export class VineUnionOfTypes return new VineUnionOfTypes([new VineNull(), ...this.#schemas]) } + /** + * Transforms into JSONSchema. + */ + protected toJSONSchema(nodes: CompilerNodes[]): JSONSchema7 { + return { + anyOf: nodes.map((node) => node.jsonSchema), + } + } + /** * Compiles to a union */ - [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): UnionNode { + [PARSE]( + propertyName: string, + refs: RefsStore, + options: ParserOptions + ): UnionNode & { jsonSchema: JSONSchema7 } { + const parsedConditions = this.#schemas.map((schema) => { + return { + conditionalFnRefId: refs.trackConditional((value, field) => { + return schema[IS_OF_TYPE]!(value, field) + }), + schema: schema[PARSE](propertyName, refs, options), + } + }) + return { type: 'union', fieldName: propertyName, propertyName: options.toCamelCase ? camelcase(propertyName) : propertyName, elseConditionalFnRefId: refs.trackConditional(this.#otherwiseCallback), - conditions: this.#schemas.map((schema) => { - return { - conditionalFnRefId: refs.trackConditional((value, field) => { - return schema[IS_OF_TYPE]!(value, field) - }), - schema: schema[PARSE](propertyName, refs, options), - } - }), + conditions: parsedConditions, + jsonSchema: this.toJSONSchema(parsedConditions.map((c) => c.schema)), } } } diff --git a/tests/unit/json_schema.spec.ts b/tests/unit/json_schema.spec.ts index 64ee20a..f3a0fa7 100644 --- a/tests/unit/json_schema.spec.ts +++ b/tests/unit/json_schema.spec.ts @@ -260,6 +260,15 @@ test.group('JsonSchema', () => { vine.object({}).meta({ description: 'Hello World!' }), { type: 'object', description: 'Hello World!', properties: {}, required: [] }, ], + [ + 'merge', + vine.object({ hello: vine.string() }), + { + type: 'object', + properties: { hello: { type: 'string' }, world: { type: 'number' } }, + required: ['hello', 'world'], + }, + ], ]) .run(({ assert }, [_, schema, expected]) => { const validator = vine.compile(schema) @@ -338,4 +347,111 @@ test.group('JsonSchema', () => { const validator = vine.compile(schema) assert.deepEqual(validator.toJSONSchema(), expected) }) + + 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(), { type: ['string', 'null'], enum: ['str'] }], + [ + 'meta', + vine.literal(false).meta({ description: 'Always false' }), + { type: 'boolean', enum: [false], description: 'Always false' }, + ], + ]) + .run(({ assert }, [_, schema, expected]) => { + const validator = vine.compile(schema) + assert.deepEqual(validator.toJSONSchema(), expected) + }) + + 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.compile(schema) + + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [ + { type: 'string', format: 'email' }, + { + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'], + }, + ], + }) + }) + + test('vine.unionOfTypes() - {0}').run(({ assert }) => { + const schema = vine.unionOfTypes([vine.string().email(), vine.number()]) + + const validator = vine.compile(schema) + + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [{ type: 'string', format: 'email' }, { type: 'number' }], + }) + }) + + 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.compile(schema) + + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [ + { + type: 'object', + properties: { + name: { type: 'string' }, + group_size: { type: 'number' }, + phone_number: { type: 'string' }, + }, + required: ['name', 'group_size', 'phone_number'], + }, + { + 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'], + }, + ], + }, + ], + } satisfies JSONSchema7) + }) }) From 312d02c2698fadb89b8b4898fb952aff6e1e1206 Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Wed, 27 Aug 2025 04:09:47 +0200 Subject: [PATCH 6/7] json schema fixes and integration tests --- package.json | 3 +- src/schema/array/main.ts | 4 +- src/schema/base/literal.ts | 8 + src/schema/boolean/rules.ts | 11 +- src/schema/object/main.ts | 1 + src/schema/record/main.ts | 5 +- src/schema/tuple/main.ts | 10 +- src/vine/helpers.ts | 4 +- tests/integration/json_schema.spec.ts | 671 +++++++++++++++++++ tests/integration/json_schema/any.spec.ts | 27 - tests/integration/json_schema/string.spec.ts | 49 -- tests/integration/json_schema/tuple.spec.ts | 22 - tests/unit/json_schema.spec.ts | 255 ++++--- 13 files changed, 859 insertions(+), 211 deletions(-) create mode 100644 tests/integration/json_schema.spec.ts delete mode 100644 tests/integration/json_schema/any.spec.ts delete mode 100644 tests/integration/json_schema/string.spec.ts delete mode 100644 tests/integration/json_schema/tuple.spec.ts diff --git a/package.json b/package.json index 1b23564..e15983b 100644 --- a/package.json +++ b/package.json @@ -147,5 +147,6 @@ "benchmarks/**" ] }, - "prettier": "@adonisjs/prettier-config" + "prettier": "@adonisjs/prettier-config", + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/schema/array/main.ts b/src/schema/array/main.ts index c9252f5..ff3a527 100644 --- a/src/schema/array/main.ts +++ b/src/schema/array/main.ts @@ -129,9 +129,7 @@ export class VineArray extends BaseType< type: 'array', } - if ('json' in node) { - schema.items = node.jsonSchema - } + schema.items = node.jsonSchema for (const validation of this.validations) { if (!validation.rule.toJSONSchema) continue diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index 43fc72a..a50deb4 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -105,6 +105,14 @@ export class NullableModifier( field.mutate(valueAsBoolean, field) }, { - toJSONSchema: (schema) => { - // TODO: We might want to handle strictness with anyOf - schema.type = 'boolean' + toJSONSchema: (schema, { strict = false }) => { + if (strict) { + schema.type = 'boolean' + } else { + schema.enum = [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] + } }, } ) diff --git a/src/schema/object/main.ts b/src/schema/object/main.ts index 0c3cba3..fe888ba 100644 --- a/src/schema/object/main.ts +++ b/src/schema/object/main.ts @@ -236,6 +236,7 @@ export class VineObject< type: 'object', properties: {}, required: [], + additionalProperties: this.#allowUnknownProperties, } for (const validation of this.validations) { diff --git a/src/schema/record/main.ts b/src/schema/record/main.ts index baedcd2..2fb52e8 100644 --- a/src/schema/record/main.ts +++ b/src/schema/record/main.ts @@ -110,10 +110,7 @@ export class VineRecord extends BaseType< additionalProperties: {}, } - // TODO: Remove condition - if ('json' in node) { - schema.additionalProperties = node.jsonSchema - } + schema.additionalProperties = node.jsonSchema for (const validation of this.validations) { if (!validation.rule.toJSONSchema) continue diff --git a/src/schema/tuple/main.ts b/src/schema/tuple/main.ts index e742c9e..8e8d3b7 100644 --- a/src/schema/tuple/main.ts +++ b/src/schema/tuple/main.ts @@ -95,16 +95,16 @@ export class VineTuple< * Transforms into JSONSchema. */ protected toJSONSchema(nodes: CompilerNodes[]) { - const schema: JSONSchema7 & { items: JSONSchema7[] } = { + const schema: JSONSchema7 & { items?: JSONSchema7[] } = { type: 'array', - items: [], + minItems: nodes.length, + maxItems: nodes.length, additionalItems: false, } for (const node of nodes) { - if ('json' in node) { - schema.items.push(node.jsonSchema) - } + if (!schema.items) schema.items = [] + schema.items.push(node.jsonSchema) } for (const validation of this.validations) { diff --git a/src/vine/helpers.ts b/src/vine/helpers.ts index f10dd66..18bbfdf 100644 --- a/src/vine/helpers.ts +++ b/src/vine/helpers.ts @@ -32,8 +32,8 @@ import { locales as postalCodeLocales } from 'validator/lib/isPostalCode.js' import type { FieldContext } from '../types.js' -const BOOLEAN_POSITIVES = ['1', 1, 'true', true, 'on'] -const BOOLEAN_NEGATIVES = ['0', 0, 'false', false] +export const BOOLEAN_POSITIVES = ['1', 1, 'true', true, 'on'] +export const BOOLEAN_NEGATIVES = ['0', 0, 'false', false] const ULID = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/ diff --git a/tests/integration/json_schema.spec.ts b/tests/integration/json_schema.spec.ts new file mode 100644 index 0000000..371e808 --- /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 { 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.compile(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/integration/json_schema/any.spec.ts b/tests/integration/json_schema/any.spec.ts deleted file mode 100644 index 4704c05..0000000 --- a/tests/integration/json_schema/any.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { test } from '@japa/runner' -import vine from '../../../index.js' -import Ajv from 'ajv' -import { SchemaTypes } from '../../../src/types.js' - -const ajv = new Ajv() - -function validate(schema: SchemaTypes, value: any) { - const validator = ajv.compile(vine.compile(schema).toJSONSchema()) - return validator(value) -} - -test.group('JsonSchema', () => { - test('base', async ({ assert }) => { - const validator = vine.any() - - assert.isTrue(validate(validator, ['Hey', 5])) - assert.isTrue(validate(validator, { HO: true })) - assert.isFalse(validate(validator, null)) - }) - - test('nullable', async ({ assert }) => { - const validator = vine.any().nullable() - - assert.isTrue(validate(validator, null)) - }) -}) diff --git a/tests/integration/json_schema/string.spec.ts b/tests/integration/json_schema/string.spec.ts deleted file mode 100644 index 2f4de8b..0000000 --- a/tests/integration/json_schema/string.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { test } from '@japa/runner' -import vine from '../../../index.js' -import Ajv from 'ajv' -import { SchemaTypes } from '../../../src/types.js' - -const ajv = new Ajv() - -function validate(schema: SchemaTypes, value: any) { - const validator = ajv.compile(vine.compile(schema).toJSONSchema()) - return validator(value) -} - -test.group('JsonSchema', () => { - test('base', async ({ assert }) => { - const validator = vine.string() - - assert.isTrue(validate(validator, 'Hello')) - assert.isFalse(validate(validator, 5)) - assert.isFalse(validate(validator, null)) - }) - - test('nullable', async ({ assert }) => { - const validator = vine.string().nullable() - assert.isTrue(validate(validator, 'Hello')) - assert.isTrue(validate(validator, null)) - assert.isFalse(validate(validator, undefined)) - }) - - test('minLength', async ({ assert }) => { - const validator = vine.string().minLength(5) - assert.isTrue(validate(validator, '12345')) - assert.isTrue(validate(validator, '12345678')) - assert.isFalse(validate(validator, '1234')) - }) - - test('maxLength', async ({ assert }) => { - const validator = vine.string().maxLength(5) - assert.isTrue(validate(validator, '1234')) - assert.isTrue(validate(validator, '12345')) - assert.isFalse(validate(validator, '123456')) - }) - - test('fixedLength', async ({ assert }) => { - const validator = vine.string().fixedLength(5) - assert.isTrue(validate(validator, '12345')) - assert.isFalse(validate(validator, '123456')) - assert.isFalse(validate(validator, '1234')) - }) -}) diff --git a/tests/integration/json_schema/tuple.spec.ts b/tests/integration/json_schema/tuple.spec.ts deleted file mode 100644 index 4f05727..0000000 --- a/tests/integration/json_schema/tuple.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test } from '@japa/runner' -import vine from '../../../index.js' -import Ajv from 'ajv' -import { SchemaTypes } from '../../../src/types.js' - -const ajv = new Ajv() - -function validate(schema: SchemaTypes, value: any) { - const validator = ajv.compile(vine.compile(schema).toJSONSchema()) - return validator(value) -} - -test.group('JsonSchema', () => { - test('base', async ({ assert }) => { - const validator = vine.tuple([vine.string(), vine.number()]) - - assert.isTrue(validate(validator, ['Hey', 5])) - assert.isFalse(validate(validator, [5, 'Hey'])) - // TODO: Handle this - // assert.isFalse(validate(validator, [])) - }) -}) diff --git a/tests/unit/json_schema.spec.ts b/tests/unit/json_schema.spec.ts index f3a0fa7..e1883e4 100644 --- a/tests/unit/json_schema.spec.ts +++ b/tests/unit/json_schema.spec.ts @@ -2,6 +2,7 @@ import { test } from '@japa/runner' import vine from '../../index.js' import { SchemaTypes } from '../../src/types.js' import { JSONSchema7 } from 'json-schema' +import { BOOLEAN_NEGATIVES, BOOLEAN_POSITIVES } from '../../src/vine/helpers.js' enum Roles { ADMIN = 'admin', @@ -79,16 +80,17 @@ test.group('JsonSchema', () => { const validator = vine.compile(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(), { type: 'null', enum: [1, 3] }], + ['nullable', vine.enum([1, 3]).nullable(), { anyOf: [{ type: 'null' }, { enum: [1, 3] }] }], [ 'nullable with predifined type', vine.enum(['foo', 'baz']).meta({ type: 'string' }).nullable(), - { type: ['string', 'null'], enum: ['foo', 'baz'] }, + { anyOf: [{ type: 'null' }, { type: 'string', enum: ['foo', 'baz'] }] }, ], [ 'meta', @@ -103,17 +105,29 @@ test.group('JsonSchema', () => { const validator = vine.compile(schema) assert.deepEqual(validator.toJSONSchema(), expected) }) + .tags(['@enum']) test('vine.boolean() - {0}') .with<[string, SchemaTypes, JSONSchema7][]>([ - ['', vine.boolean(), { type: 'boolean' }], - ['nullable', vine.boolean().nullable(), { type: ['boolean', 'null'] }], - ['meta', vine.boolean().meta({ examples: [true] }), { type: 'boolean', examples: [true] }], + ['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: [{ type: 'null' }, { enum: [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] }] }, + ], + [ + 'meta', + vine.boolean({ strict: true }).meta({ examples: [true] }), + { type: 'boolean', examples: [true] }, + ], ]) .run(({ assert }, [_, schema, expected]) => { const validator = vine.compile(schema) assert.deepEqual(validator.toJSONSchema(), expected) }) + .tags(['@boolean']) test('vine.any() - {0}') .with<[string, SchemaTypes, JSONSchema7][]>([ @@ -163,6 +177,7 @@ test.group('JsonSchema', () => { const validator = vine.compile(schema) assert.deepEqual(validator.toJSONSchema(), expected) }) + .tags(['@any']) test('vine.record().{0}') .with<[string, SchemaTypes, JSONSchema7][]>([ @@ -210,11 +225,16 @@ test.group('JsonSchema', () => { const validator = vine.compile(schema) assert.deepEqual(validator.toJSONSchema(), expected) }) + .tags(['@record']) // TODO: We might want to add `additionalProperties: false` test('vine.object() - {0}') .with<[string, SchemaTypes, JSONSchema7][]>([ - ['empty', vine.object({}), { type: 'object', properties: {}, required: [] }], + [ + 'empty', + vine.object({}), + { type: 'object', properties: {}, required: [], additionalProperties: false }, + ], [ 'properties', vine.object({ hello: vine.string(), world: vine.number() }), @@ -229,6 +249,21 @@ test.group('JsonSchema', () => { }, }, required: ['hello', 'world'], + additionalProperties: false, + }, + ], + [ + 'allowUnknownProperties', + vine.object({ hello: vine.string() }).allowUnknownProperties(), + { + type: 'object', + properties: { + hello: { + type: 'string', + }, + }, + required: ['hello'], + additionalProperties: true, }, ], [ @@ -248,25 +283,23 @@ test.group('JsonSchema', () => { }, }, required: ['baz'], + additionalProperties: false, }, ], [ 'nullable', vine.object({}).nullable(), - { type: ['object', 'null'], properties: {}, required: [] }, + { type: ['object', 'null'], properties: {}, required: [], additionalProperties: false }, ], [ 'meta', vine.object({}).meta({ description: 'Hello World!' }), - { type: 'object', description: 'Hello World!', properties: {}, required: [] }, - ], - [ - 'merge', - vine.object({ hello: vine.string() }), { type: 'object', - properties: { hello: { type: 'string' }, world: { type: 'number' } }, - required: ['hello', 'world'], + description: 'Hello World!', + properties: {}, + required: [], + additionalProperties: false, }, ], ]) @@ -274,6 +307,7 @@ test.group('JsonSchema', () => { const validator = vine.compile(schema) assert.deepEqual(validator.toJSONSchema(), expected) }) + .tags(['@object']) test('vine.array() - {0}') .with<[string, SchemaTypes, JSONSchema7][]>([ @@ -310,7 +344,7 @@ test.group('JsonSchema', () => { ], [ 'meta', - vine.array(vine.boolean()).meta({ examples: [[true, false, false]] }), + vine.array(vine.boolean({ strict: true })).meta({ examples: [[true, false, false]] }), { type: 'array', items: { type: 'boolean' }, examples: [[true, false, false]] }, ], ]) @@ -318,26 +352,43 @@ test.group('JsonSchema', () => { const validator = vine.compile(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' }], additionalItems: false }, + { + 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' }], additionalItems: false }, + { + type: ['array', 'null'], + items: [{ type: 'string' }], + minItems: 1, + maxItems: 1, + additionalItems: false, + }, ], [ 'meta', - vine.tuple([vine.boolean(), vine.number()]).meta({ description: 'A tuple' }), + 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', }, @@ -347,13 +398,20 @@ test.group('JsonSchema', () => { const validator = vine.compile(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(), { type: ['string', 'null'], enum: ['str'] }], + [ + 'nullable', + vine.literal('str').nullable(), + { + anyOf: [{ type: 'null' }, { type: 'string', enum: ['str'] }], + }, + ], [ 'meta', vine.literal(false).meta({ description: 'Always false' }), @@ -364,94 +422,103 @@ test.group('JsonSchema', () => { const validator = vine.compile(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() }) - ), - ]) + 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.compile(schema) + const validator = vine.compile(schema) - assert.deepEqual(validator.toJSONSchema(), { - anyOf: [ - { type: 'string', format: 'email' }, - { - type: 'object', - properties: { email: { type: 'string', format: 'email' } }, - required: ['email'], - }, - ], + 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()]) + test('vine.unionOfTypes() - {0}') + .run(({ assert }) => { + const schema = vine.unionOfTypes([vine.string().email(), vine.number()]) - const validator = vine.compile(schema) + const validator = vine.compile(schema) - assert.deepEqual(validator.toJSONSchema(), { - anyOf: [{ type: 'string', format: 'email' }, { type: 'number' }], + 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), - }), - ]) + 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 schema = vine + .object({ + name: vine.string(), + group_size: vine.number(), + phone_number: vine.string(), + }) + .merge(guideSchema) - const validator = vine.compile(schema) + const validator = vine.compile(schema) - assert.deepEqual(validator.toJSONSchema(), { - anyOf: [ - { - type: 'object', - properties: { - name: { type: 'string' }, - group_size: { type: 'number' }, - phone_number: { type: 'string' }, + assert.deepEqual(validator.toJSONSchema(), { + anyOf: [ + { + type: 'object', + properties: { + name: { type: 'string' }, + group_size: { type: 'number' }, + phone_number: { type: 'string' }, + }, + required: ['name', 'group_size', 'phone_number'], + additionalProperties: false, }, - required: ['name', 'group_size', 'phone_number'], - }, - { - anyOf: [ - { - type: 'object', - properties: { - is_hiring_guide: { type: 'boolean', enum: [true] }, - guide_id: { type: 'string' }, - amount: { type: 'number' }, + { + 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'], }, - required: ['is_hiring_guide', 'guide_id', 'amount'], - }, - { - type: 'object', - properties: { - is_hiring_guide: { type: 'boolean', enum: [false] }, + { + type: 'object', + properties: { + is_hiring_guide: { type: 'boolean', enum: [false] }, + }, + required: ['is_hiring_guide'], }, - required: ['is_hiring_guide'], - }, - ], - }, - ], - } satisfies JSONSchema7) - }) + ], + }, + ], + } satisfies JSONSchema7) + }) + .tags(['@group']) }) From e0620acf225ac809b074db8308e022fe50e28fea Mon Sep 17 00:00:00 2001 From: Martin PAUCOT Date: Tue, 23 Dec 2025 04:05:51 +0100 Subject: [PATCH 7/7] move schema computation out of parser --- package.json | 3 +- src/schema/any/main.ts | 20 +---- src/schema/array/main.ts | 26 ++---- src/schema/base/literal.ts | 136 ++++++++++++++++++++---------- src/schema/base/main.ts | 93 ++++++++++++++------ src/schema/boolean/main.ts | 2 +- src/schema/date/main.ts | 2 +- src/schema/literal/main.ts | 11 +-- src/schema/null/main.ts | 18 ++-- src/schema/number/main.ts | 2 +- src/schema/object/conditional.ts | 42 +++++---- src/schema/object/group.ts | 21 ++--- src/schema/object/main.ts | 82 +++++++++--------- src/schema/optional/main.ts | 10 ++- src/schema/record/main.ts | 33 ++++---- src/schema/tuple/main.ts | 39 +++++---- src/schema/union/conditional.ts | 12 +-- src/schema/union/main.ts | 46 ++++------ src/schema/union_of_types/main.ts | 41 ++++----- src/types.ts | 20 ++++- src/vine/validator.ts | 14 ++- tests/unit/json_schema.spec.ts | 44 ++++++---- 22 files changed, 395 insertions(+), 322 deletions(-) diff --git a/package.json b/package.json index db83454..4c3a200 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,5 @@ "benchmarks/**" ] }, - "prettier": "@adonisjs/prettier-config", - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "prettier": "@adonisjs/prettier-config" } diff --git a/src/schema/any/main.ts b/src/schema/any/main.ts index 7d7677e..61271c2 100644 --- a/src/schema/any/main.ts +++ b/src/schema/any/main.ts @@ -8,9 +8,8 @@ */ import { BaseLiteralType } from '../base/literal.js' -import type { FieldOptions, ParserOptions, Validation } from '../../types.js' -import { PARSE, SUBTYPE } from '../../symbols.js' -import { type RefsStore, type LiteralNode } from '@vinejs/compiler/types' +import type { FieldOptions, Validation } from '../../types.js' +import { SUBTYPE } from '../../symbols.js' import { type JSONSchema7 } from 'json-schema' /** @@ -37,7 +36,7 @@ export class VineAny extends BaseLiteralType { /** * Transforms into JSONSchema. */ - protected toJSONSchema(): JSONSchema7 { + toJSONSchema(): JSONSchema7 { const schema: JSONSchema7 = { anyOf: [ { type: 'string' }, @@ -55,17 +54,4 @@ export class VineAny extends BaseLiteralType { return schema } - - /** - * Compiles the schema type to a compiler node - */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { - const schema = super[PARSE](propertyName, refs, options) - schema.jsonSchema = this.toJSONSchema() - return schema - } } diff --git a/src/schema/array/main.ts b/src/schema/array/main.ts index e241998..542aa28 100644 --- a/src/schema/array/main.ts +++ b/src/schema/array/main.ts @@ -20,11 +20,11 @@ import { IS_OF_TYPE, } from '../../symbols.js' import type { - CompilerNodes, FieldOptions, ParserOptions, SchemaTypes, Validation, + WithJSONSchema, } from '../../types.js' import { @@ -55,11 +55,10 @@ import { type JSONSchema7 } from 'json-schema' * 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 */ @@ -176,13 +175,12 @@ export class VineArray extends BaseType< /** * Transforms into JSONSchema. */ - protected toJSONSchema(node: CompilerNodes): JSONSchema7 { + toJSONSchema(): JSONSchema7 { const schema: JSONSchema7 = { type: 'array', + items: this.#schema.toJSONSchema?.() ?? {}, } - schema.items = node.jsonSchema - for (const validation of this.validations) { if (!validation.rule.toJSONSchema) continue validation.rule.toJSONSchema(schema, validation.options) @@ -199,12 +197,7 @@ export class VineArray extends BaseType< * @param options - Parser options * @returns Compiled array node for validation */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): ArrayNode & { jsonSchema: JSONSchema7 } { - const parsed = this.#schema[PARSE]('*', refs, options) + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): ArrayNode { return { type: 'array', fieldName: propertyName, @@ -212,10 +205,9 @@ export class VineArray extends BaseType< bail: this.options.bail, allowNull: this.options.allowNull, isOptional: this.options.isOptional, - each: parsed, + each: this.#schema[PARSE]('*', refs, options), parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - jsonSchema: this.toJSONSchema(parsed), } } } diff --git a/src/schema/base/literal.ts b/src/schema/base/literal.ts index 917a19d..4337e52 100644 --- a/src/schema/base/literal.ts +++ b/src/schema/base/literal.ts @@ -98,46 +98,48 @@ export class NullableModifier< return new MetaModifier(this, meta) } - /** - * Compiles to compiler node - */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { - const output = this.#parent[PARSE](propertyName, refs, options) - output.allowNull = true + toJSONSchema(): JSONSchema7 { + const schema = this.#parent.toJSONSchema?.() ?? {} - // TODO: We might want to dedupe - if (output.jsonSchema.anyOf) { - output.jsonSchema.anyOf.push({ type: 'null' }) - return output + if (schema.anyOf) { + schema.anyOf.push({ type: 'null' }) + return schema } - if (output.jsonSchema.enum) { - output.jsonSchema = { - anyOf: [{ type: 'null' }, output.jsonSchema], + if (schema.enum) { + return { + anyOf: [schema, { type: 'null' }], } - - return output } - if (output.jsonSchema.type === undefined) { - output.jsonSchema.type = 'null' - return output + if (schema.type === undefined) { + schema.type = 'null' + return schema } - if (typeof output.jsonSchema.type === 'string') { - output.jsonSchema.type = [output.jsonSchema.type, 'null'] - return output + if (typeof schema.type === 'string') { + schema.type = [schema.type, 'null'] + return schema } - if (Array.isArray(output.jsonSchema.type)) { - output.jsonSchema.type.push('null') - return output + 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 } } @@ -180,6 +182,25 @@ export class MetaModifier< 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. @@ -199,6 +220,14 @@ export class MetaModifier< 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 */ @@ -206,16 +235,9 @@ export class MetaModifier< propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { + ): LiteralNode & { subtype: string } { const output = this.#parent[PARSE](propertyName, refs, options) output.allowNull = true - - // TODO: We might want to deepmerge - output.jsonSchema = { - ...output.jsonSchema, - ...this.#meta, - } - return output } } @@ -346,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 */ @@ -353,7 +391,7 @@ export class OptionalModifier { + return new MetaModifier(this, meta) + } + + toJSONSchema(): JSONSchema7 { + return this.#parent.toJSONSchema() + } + /** * Compiles to compiler node */ @@ -441,7 +491,7 @@ export class TransformModifier< propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { + ): LiteralNode & { subtype: string } { const output = this.#parent[PARSE](propertyName, refs, options) output.transformFnId = refs.trackTransformer(this.#transform) return output @@ -508,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 @@ -586,10 +636,7 @@ export abstract class BaseLiteralType return this.validations.map((validation) => this.compileValidation(validation, refs)) } - /** - * Transforms into JSONSchema. - */ - protected toJSONSchema() { + toJSONSchema(): JSONSchema7 { const schema: JSONSchema7 = {} if (this.dataTypeValidator?.rule.toJSONSchema) { @@ -698,7 +745,7 @@ export abstract class BaseLiteralType propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { + ): LiteralNode & { subtype: string } { return { type: 'literal', subtype: this[SUBTYPE], @@ -712,7 +759,6 @@ export abstract class BaseLiteralType isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - jsonSchema: this.toJSONSchema(), } } } diff --git a/src/schema/base/main.ts b/src/schema/base/main.ts index d21721b..f46a424 100644 --- a/src/schema/base/main.ts +++ b/src/schema/base/main.ts @@ -97,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. @@ -110,27 +140,6 @@ export class NullableModifier< const output = this.#parent[PARSE](propertyName, refs, options) if (output.type !== 'union') { output.allowNull = true - - // TODO: We might want to dedupe - if (output.jsonSchema.anyOf) { - output.jsonSchema.anyOf.push({ type: 'null' }) - return output - } - - if (output.jsonSchema.type === undefined) { - output.jsonSchema.type = 'null' - return output - } - - if (typeof output.jsonSchema.type === 'string') { - output.jsonSchema.type = [output.jsonSchema.type, 'null'] - return output - } - - if (Array.isArray(output.jsonSchema.type)) { - output.jsonSchema.type.push('null') - return output - } } return output @@ -172,15 +181,25 @@ export class MetaModifier< return new MetaModifier(this.#parent.clone(), this.#meta) as this } - [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): CompilerNodes { - const output = this.#parent[PARSE](propertyName, refs, options) - - output.jsonSchema = { - ...output.jsonSchema, + toJSONSchema(): JSONSchema7 { + const parent = this.#parent.toJSONSchema?.() ?? {} + return { + ...parent, ...this.#meta, } + } - return output + /** + * 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) } } @@ -310,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 */ @@ -356,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. 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 & DateFieldOptions, validations?: Validation[]) { super(options, validations || []) diff --git a/src/schema/literal/main.ts b/src/schema/literal/main.ts index 16f687b..f41dd5b 100644 --- a/src/schema/literal/main.ts +++ b/src/schema/literal/main.ts @@ -11,13 +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 */ @@ -62,7 +65,7 @@ export class VineLiteral extends BaseLiteralType extends BaseLiteralType { +export class VineNull implements ConstructableSchema, WithJSONSchema { /** * The input type of the schema */ @@ -77,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 */ @@ -84,7 +93,7 @@ export class VineNull implements ConstructableSchema { propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { + ): LiteralNode & { subtype: string } { return { type: 'literal', subtype: this[SUBTYPE], @@ -95,9 +104,6 @@ export class VineNull implements ConstructableSchema { isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: [], - jsonSchema: { - type: 'null', - }, } } } 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/object/conditional.ts b/src/schema/object/conditional.ts index 8c23663..0aa2768 100644 --- a/src/schema/object/conditional.ts +++ b/src/schema/object/conditional.ts @@ -10,7 +10,7 @@ import type { ConditionalFn, ObjectGroupNode, RefsStore } from '@vinejs/compiler/types' import { OTYPE, COTYPE, PARSE, ITYPE } from '../../symbols.js' -import type { CompilerNodes, ParserOptions, SchemaTypes } from '../../types.js' +import type { ParserOptions, SchemaTypes, WithJSONSchema } from '../../types.js' import { type JSONSchema7 } from 'json-schema' /** @@ -22,7 +22,7 @@ export class GroupConditional< Input, Output, CamelCaseOutput, -> { +> implements WithJSONSchema { declare [ITYPE]: Input; declare [OTYPE]: Output; declare [COTYPE]: CamelCaseOutput @@ -45,43 +45,39 @@ export class GroupConditional< /** * Transforms into JSONSchema. */ - protected toJSONSchema(nodes: CompilerNodes[]) { - const schema: JSONSchema7 & { properties: {}; required: [] } = { - type: 'object', - properties: {}, - required: [], - } + toJSONSchema(): JSONSchema7 { + const properties: Record = {} + const required: string[] = [] - for (const node of nodes) { - schema.properties[node.propertyName] = node.jsonSchema + for (const [key, property] of Object.entries(this.#properties)) { + const schema = property.toJSONSchema() + properties[key] = schema - if (!('isOptional' in node) || !node.isOptional) { - schema.required.push(node.propertyName) + if (!('isOptional' in property) || property.isOptional !== true) { + required.push(key) } } - return schema + return { + type: 'object', + properties, + required, + } } /** * Compiles to a union conditional */ - [PARSE]( - refs: RefsStore, - options: ParserOptions - ): ObjectGroupNode['conditions'][number] & { jsonSchema: JSONSchema7 } { - const parsedProperties = Object.keys(this.#properties).map((property) => { - return this.#properties[property][PARSE](property, refs, options) - }) - + [PARSE](refs: RefsStore, options: ParserOptions): ObjectGroupNode['conditions'][number] { return { schema: { type: 'sub_object', - properties: parsedProperties, + properties: Object.keys(this.#properties).map((property) => { + return this.#properties[property][PARSE](property, refs, options) + }), groups: [], // Compiler allows nested groups, but we are not implementing it }, conditionalFnRefId: refs.trackConditional(this.#conditional), - jsonSchema: this.toJSONSchema(parsedProperties), } } } diff --git a/src/schema/object/group.ts b/src/schema/object/group.ts index 746b1e7..a216e77 100644 --- a/src/schema/object/group.ts +++ b/src/schema/object/group.ts @@ -12,7 +12,7 @@ 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' /** @@ -20,7 +20,9 @@ import { type JSONSchema7 } from 'json-schema' * 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] @@ -37,11 +39,9 @@ export class ObjectGroup group.jsonSchema), + anyOf: this.#conditionals.map((conditional) => conditional.toJSONSchema()), } } @@ -66,16 +66,11 @@ export class ObjectGroup - conditional[PARSE](refs, options) - ) - + [PARSE](refs: RefsStore, options: ParserOptions): ObjectGroupNode { return { type: 'group', elseConditionalFnRefId: refs.trackConditional(this.#otherwiseCallback), - conditions: parsedConditions, - jsonSchema: this.toJSONSchema(parsedConditions), + conditions: this.#conditionals.map((conditional) => conditional[PARSE](refs, options)), } } } diff --git a/src/schema/object/main.ts b/src/schema/object/main.ts index 4939e77..2785d4f 100644 --- a/src/schema/object/main.ts +++ b/src/schema/object/main.ts @@ -9,7 +9,7 @@ import camelcase from 'camelcase' import { type Prettify } from '@poppinss/types' -import type { ObjectGroupNode, ObjectNode, RefsStore } from '@vinejs/compiler/types' +import type { ObjectNode, RefsStore } from '@vinejs/compiler/types' import { type ObjectGroup } from './group.js' import { BaseType } from '../base/main.js' @@ -27,9 +27,9 @@ import type { SchemaTypes, FieldOptions, ParserOptions, - CompilerNodes, PropertiesToOptional, UndefinedOptional, + WithJSONSchema, } from '../../types.js' import { type JSONSchema7 } from 'json-schema' import type { CamelCase } from '../camelcase_types.ts' @@ -94,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. * @@ -102,11 +107,7 @@ export class VineCamelCaseObject> * @param options - Parser options * @returns Compiled object node with camelCase conversion */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): ObjectNode & { jsonSchema: JSONSchema7 } { + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): ObjectNode { options.toCamelCase = true return this.#schema[PARSE](propertyName, refs, options) } @@ -139,7 +140,10 @@ export class VineObject< Input, Output, CamelCaseOutput, -> extends BaseType { +> + extends BaseType + implements WithJSONSchema +{ /** * Object properties mapping property names to their validation schemas */ @@ -289,17 +293,24 @@ export class VineObject< return new VineCamelCaseObject(this) } - /** - * Transforms into JSONSchema. - */ - protected toJSONSchema( - nodes: CompilerNodes[], - groups: (ObjectGroupNode & { jsonSchema: JSONSchema7 })[] - ): JSONSchema7 { - const schema: JSONSchema7 & { properties: {}; required: [] } = { + 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: [], + properties, + required, additionalProperties: this.#allowUnknownProperties, } @@ -308,17 +319,9 @@ export class VineObject< validation.rule.toJSONSchema(schema, validation.options) } - for (const node of nodes) { - schema.properties[node.propertyName] = node.jsonSchema - - if (!('isOptional' in node) || !node.isOptional) { - schema.required.push(node.propertyName) - } - } - - if (groups.length > 0) { + if (this.#groups.length > 0) { return { - anyOf: [schema, ...groups.map((group) => group.jsonSchema)], + anyOf: [...this.#groups.map((group) => group.toJSONSchema()), schema], } } @@ -389,19 +392,7 @@ export class VineObject< /** * Compiles the schema type to a compiler node */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): ObjectNode & { jsonSchema: JSONSchema7 } { - const parsedProperties = Object.keys(this.#properties).map((property) => { - return this.#properties[property][PARSE](property, refs, options) - }) - - const parsedGroups = this.#groups.map((group) => { - return group[PARSE](refs, options) - }) - + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): ObjectNode { return { type: 'object', fieldName: propertyName, @@ -412,9 +403,12 @@ export class VineObject< parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, allowUnknownProperties: this.#allowUnknownProperties, validations: this.compileValidations(refs), - properties: parsedProperties, - groups: parsedGroups, - jsonSchema: this.toJSONSchema(parsedProperties, parsedGroups), + properties: Object.keys(this.#properties).map((property) => { + return this.#properties[property][PARSE](property, refs, options) + }), + groups: this.#groups.map((group) => { + return group[PARSE](refs, options) + }), } } } diff --git a/src/schema/optional/main.ts b/src/schema/optional/main.ts index c48c116..232cf6f 100644 --- a/src/schema/optional/main.ts +++ b/src/schema/optional/main.ts @@ -171,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 */ @@ -178,7 +185,7 @@ export class VineOptional propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } { + ): LiteralNode & { subtype: string } { return { type: 'literal', subtype: this[SUBTYPE], @@ -189,7 +196,6 @@ export class VineOptional isOptional: this.options.isOptional, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - jsonSchema: {}, } } } diff --git a/src/schema/record/main.ts b/src/schema/record/main.ts index 99e040f..7c079bb 100644 --- a/src/schema/record/main.ts +++ b/src/schema/record/main.ts @@ -20,11 +20,11 @@ import { IS_OF_TYPE, } from '../../symbols.js' import type { - CompilerNodes, FieldOptions, ParserOptions, SchemaTypes, Validation, + WithJSONSchema, } from '../../types.js' import { fixedLengthRule, maxLengthRule, minLengthRule, validateKeysRule } from './rules.js' import { type JSONSchema7 } from 'json-schema' @@ -33,11 +33,14 @@ 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 */ @@ -111,13 +114,13 @@ export class VineRecord extends BaseType< /** * Transforms into JSONSchema. */ - protected toJSONSchema(node: CompilerNodes) { - const schema: JSONSchema7 & {} = { + toJSONSchema() { + const schema = { type: 'object', additionalProperties: {}, - } + } satisfies JSONSchema7 - schema.additionalProperties = node.jsonSchema + schema.additionalProperties = this.#schema.toJSONSchema?.() ?? {} for (const validation of this.validations) { if (!validation.rule.toJSONSchema) continue @@ -130,12 +133,7 @@ export class VineRecord extends BaseType< /** * Compiles to record data type */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): RecordNode & { jsonSchema: JSONSchema7 } { - const parsed = this.#schema[PARSE]('*', refs, options) + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): RecordNode { return { type: 'record', fieldName: propertyName, @@ -143,10 +141,9 @@ export class VineRecord extends BaseType< bail: this.options.bail, allowNull: this.options.allowNull, isOptional: this.options.isOptional, - each: parsed, + each: this.#schema[PARSE]('*', refs, options), parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - jsonSchema: this.toJSONSchema(parsed), } } } diff --git a/src/schema/tuple/main.ts b/src/schema/tuple/main.ts index 2e11e5d..4f098e6 100644 --- a/src/schema/tuple/main.ts +++ b/src/schema/tuple/main.ts @@ -13,11 +13,11 @@ 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 { - CompilerNodes, FieldOptions, ParserOptions, SchemaTypes, Validation, + WithJSONSchema, } from '../../types.js' import { type JSONSchema7 } from 'json-schema' @@ -30,7 +30,10 @@ export class VineTuple< Input extends any[], Output extends any[], CamelCaseOutput extends any[], -> extends BaseType { +> + extends BaseType + implements WithJSONSchema +{ #schemas: [...Schema] /** @@ -94,17 +97,20 @@ export class VineTuple< /** * Transforms into JSONSchema. */ - protected toJSONSchema(nodes: CompilerNodes[]) { - const schema: JSONSchema7 & { items?: JSONSchema7[] } = { - type: 'array', - minItems: nodes.length, - maxItems: nodes.length, - additionalItems: false, + toJSONSchema() { + const items: JSONSchema7[] = [] + for (const item of this.#schemas) { + if (!item.toJSONSchema) continue + items.push(item.toJSONSchema()) } - for (const node of nodes) { - if (!schema.items) schema.items = [] - schema.items.push(node.jsonSchema) + 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) { @@ -118,13 +124,7 @@ export class VineTuple< /** * Compiles to array data type */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): TupleNode & { jsonSchema: JSONSchema7 } { - const parsed = this.#schemas.map((schema, index) => schema[PARSE](String(index), refs, options)) - + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): TupleNode { return { type: 'tuple', fieldName: propertyName, @@ -135,8 +135,7 @@ export class VineTuple< allowUnknownProperties: this.#allowUnknownProperties, parseFnId: this.options.parse ? refs.trackParser(this.options.parse) : undefined, validations: this.compileValidations(refs), - properties: parsed, - jsonSchema: this.toJSONSchema(parsed), + properties: this.#schemas.map((schema, index) => schema[PARSE](String(index), refs, options)), } } } diff --git a/src/schema/union/conditional.ts b/src/schema/union/conditional.ts index 37710e8..305e9dc 100644 --- a/src/schema/union/conditional.ts +++ b/src/schema/union/conditional.ts @@ -11,7 +11,6 @@ import { type ConditionalFn, type RefsStore, type UnionNode } from '@vinejs/comp import { ITYPE, OTYPE, COTYPE, PARSE } from '../../symbols.js' import type { ParserOptions, SchemaTypes } from '../../types.js' -import { type JSONSchema7 } from 'json-schema' /** * Represents a union conditional type. A conditional is a predicate @@ -37,6 +36,10 @@ export class UnionConditional { this.#conditional = conditional } + toJSONSchema() { + return this.#schema.toJSONSchema?.() + } + /** * Compiles to a union conditional */ @@ -44,13 +47,10 @@ export class UnionConditional { propertyName: string, refs: RefsStore, options: ParserOptions - ): UnionNode['conditions'][number] & { jsonSchema: JSONSchema7 } { - const parsedSchema = this.#schema[PARSE](propertyName, refs, options) - + ): UnionNode['conditions'][number] { return { conditionalFnRefId: refs.trackConditional(this.#conditional), - schema: parsedSchema, - jsonSchema: parsedSchema.jsonSchema, + schema: this.#schema[PARSE](propertyName, refs, options), } } } diff --git a/src/schema/union/main.ts b/src/schema/union/main.ts index 865fa3b..f4c55a8 100644 --- a/src/schema/union/main.ts +++ b/src/schema/union/main.ts @@ -8,7 +8,7 @@ */ import camelcase from 'camelcase' -import { type CompilerNodes, type RefsStore, type UnionNode } from '@vinejs/compiler/types' +import { type RefsStore, type UnionNode } from '@vinejs/compiler/types' import { messages } from '../../defaults.js' import { UnionConditional } from './conditional.js' @@ -18,7 +18,7 @@ import type { ParserOptions, ConstructableSchema, UnionNoMatchCallback, - RefIdentifier, + WithJSONSchema, } from '../../types.js' import { VineOptional } from '../optional/main.js' import { VineNull } from '../null/main.js' @@ -28,13 +28,15 @@ 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] @@ -87,16 +89,9 @@ export class VineUnion< /** * Transforms into JSONSchema. */ - protected toJSONSchema( - conditions: ({ - conditionalFnRefId: RefIdentifier - schema: CompilerNodes - } & { - jsonSchema: JSONSchema7 - })[] - ): JSONSchema7 { + toJSONSchema(): JSONSchema7 { return { - anyOf: conditions.map((condition) => condition.jsonSchema), + anyOf: this.#conditionals.map((conditional) => conditional.toJSONSchema()).filter(Boolean), } } @@ -113,22 +108,15 @@ export class VineUnion< /** * Compiles to a union */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): UnionNode & { jsonSchema: JSONSchema7 } { - const parsedConditions = this.#conditionals.map((conditional) => - conditional[PARSE](propertyName, refs, options) - ) - + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): UnionNode { return { type: 'union', fieldName: propertyName, propertyName: options.toCamelCase ? camelcase(propertyName) : propertyName, elseConditionalFnRefId: refs.trackConditional(this.#otherwiseCallback), - conditions: parsedConditions, - jsonSchema: this.toJSONSchema(parsedConditions), + conditions: this.#conditionals.map((conditional) => + conditional[PARSE](propertyName, refs, options) + ), } } } diff --git a/src/schema/union_of_types/main.ts b/src/schema/union_of_types/main.ts index 1e7a1a5..5b5df37 100644 --- a/src/schema/union_of_types/main.ts +++ b/src/schema/union_of_types/main.ts @@ -17,7 +17,7 @@ import type { ParserOptions, ConstructableSchema, UnionNoMatchCallback, - CompilerNodes, + WithJSONSchema, } from '../../types.js' import { VineOptional } from '../optional/main.js' import { VineNull } from '../null/main.js' @@ -27,11 +27,11 @@ 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] @@ -89,36 +89,29 @@ export class VineUnionOfTypes implements Constructab /** * Transforms into JSONSchema. */ - protected toJSONSchema(nodes: CompilerNodes[]): JSONSchema7 { + toJSONSchema(): JSONSchema7 { return { - anyOf: nodes.map((node) => node.jsonSchema), + anyOf: this.#schemas.map((schema) => schema.toJSONSchema?.()).filter(Boolean), } } /** * Compiles to a union */ - [PARSE]( - propertyName: string, - refs: RefsStore, - options: ParserOptions - ): UnionNode & { jsonSchema: JSONSchema7 } { - const parsedConditions = this.#schemas.map((schema) => { - return { - conditionalFnRefId: refs.trackConditional((value, field) => { - return schema[IS_OF_TYPE]!(value, field) - }), - schema: schema[PARSE](propertyName, refs, options), - } - }) - + [PARSE](propertyName: string, refs: RefsStore, options: ParserOptions): UnionNode { return { type: 'union', fieldName: propertyName, propertyName: options.toCamelCase ? camelcase(propertyName) : propertyName, elseConditionalFnRefId: refs.trackConditional(this.#otherwiseCallback), - conditions: parsedConditions, - jsonSchema: this.toJSONSchema(parsedConditions.map((c) => c.schema)), + conditions: this.#schemas.map((schema) => { + return { + conditionalFnRefId: refs.trackConditional((value, field) => { + return schema[IS_OF_TYPE]!(value, field) + }), + schema: schema[PARSE](propertyName, refs, options), + } + }), } } } diff --git a/src/types.ts b/src/types.ts index 73c17b1..b2184fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,14 +52,13 @@ import { type JSONSchema7 } from 'json-schema' * validations: [] * } */ -export type CompilerNodes = ( +export type CompilerNodes = | (LiteralNode & { subtype: string }) | ObjectNode | ArrayNode | UnionNode | RecordNode | TupleNode -) & { jsonSchema: JSONSchema7 } /** * Options accepted by the mobile number validation rule. @@ -326,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 } /** @@ -361,9 +363,11 @@ export interface ConstructableLiteralSchema { propertyName: string, refs: RefsStore, options: ParserOptions - ): LiteralNode & { subtype: string; jsonSchema: JSONSchema7 } + ): 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. @@ -398,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. diff --git a/src/vine/validator.ts b/src/vine/validator.ts index b8d085b..1608f19 100644 --- a/src/vine/validator.ts +++ b/src/vine/validator.ts @@ -20,7 +20,6 @@ import type { MetaDataValidator, ValidationOptions, ErrorReporterContract, - CompilerNodes, } from '../types.js' import { type JSONSchema7 } from 'json-schema' @@ -70,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 @@ -242,8 +247,11 @@ export class VineValidator< } 'toJSONSchema'(): JSONSchema7 { - const schema = this.#compiled.schema.schema as CompilerNodes - return schema.jsonSchema + if (!this.#jsonSchema) { + this.#jsonSchema = this.schema.toJSONSchema() + } + + return this.#jsonSchema } readonly '~standard': StandardSchemaV1.Props = { diff --git a/tests/unit/json_schema.spec.ts b/tests/unit/json_schema.spec.ts index e53dfef..1047fa8 100644 --- a/tests/unit/json_schema.spec.ts +++ b/tests/unit/json_schema.spec.ts @@ -3,6 +3,7 @@ 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', @@ -86,11 +87,11 @@ test.group('JsonSchema', () => { .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: [{ type: 'null' }, { enum: [1, 3] }] }], + ['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: 'null' }, { type: 'string', enum: ['foo', 'baz'] }] }, + { anyOf: [{ type: 'string', enum: ['foo', 'baz'] }, { type: 'null' }] }, ], [ 'meta', @@ -115,7 +116,7 @@ test.group('JsonSchema', () => { [ 'not strict nullable', vine.boolean().nullable(), - { anyOf: [{ type: 'null' }, { enum: [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] }] }, + { anyOf: [{ enum: [...BOOLEAN_POSITIVES, ...BOOLEAN_NEGATIVES] }, { type: 'null' }] }, ], [ 'meta', @@ -227,7 +228,6 @@ test.group('JsonSchema', () => { }) .tags(['@record']) - // TODO: We might want to add `additionalProperties: false` test('vine.object() - {0}') .with<[string, SchemaTypes, JSONSchema7][]>([ [ @@ -277,6 +277,8 @@ test.group('JsonSchema', () => { properties: { foo: { type: 'number', + // @ts-expect-error -- this is not part of standard (used for context) + isOptional: true, }, baz: { type: 'string', @@ -409,7 +411,7 @@ test.group('JsonSchema', () => { 'nullable', vine.literal('str').nullable(), { - anyOf: [{ type: 'null' }, { type: 'string', enum: ['str'] }], + anyOf: [{ type: 'string', enum: ['str'] }, { type: 'null' }], }, ], [ @@ -487,16 +489,6 @@ test.group('JsonSchema', () => { assert.deepEqual(validator.toJSONSchema(), { anyOf: [ - { - type: 'object', - properties: { - name: { type: 'string' }, - group_size: { type: 'number' }, - phone_number: { type: 'string' }, - }, - required: ['name', 'group_size', 'phone_number'], - additionalProperties: false, - }, { anyOf: [ { @@ -517,8 +509,30 @@ test.group('JsonSchema', () => { }, ], }, + { + 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', + }) + }) })