diff --git a/packages/zui/package.json b/packages/zui/package.json index 91677a51a3b..3bdd2ab47ee 100644 --- a/packages/zui/package.json +++ b/packages/zui/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/zui", - "version": "2.1.1", + "version": "2.2.1", "description": "A fork of Zod with additional features", "type": "module", "source": "./src/index.ts", diff --git a/packages/zui/src/transforms/common/errors.ts b/packages/zui/src/transforms/common/errors.ts index 964369bd2ed..4306dab172b 100644 --- a/packages/zui/src/transforms/common/errors.ts +++ b/packages/zui/src/transforms/common/errors.ts @@ -11,84 +11,87 @@ type Transform = export abstract class ZuiTransformError extends Error { public constructor( public readonly transform: Transform, - message?: string + message?: string, + public readonly path?: string ) { - super(message) + const msg = path ? `${path} : ${message}` : message + super(msg) } } // json-schema-to-zui-error export class JSONSchemaToZuiError extends ZuiTransformError { - public constructor(message?: string) { - super('json-schema-to-zui', message) + public constructor(message?: string, path?: string) { + super('json-schema-to-zui', message, path) + } +} +export class UnsupportedJSONSchemaToZuiError extends JSONSchemaToZuiError { + public constructor(schema: JSONSchema7, path?: string) { + super(`JSON Schema ${JSON.stringify(schema)} cannot be transformed to ZUI type.`, path) } } // object-to-zui-error export class ObjectToZuiError extends ZuiTransformError { - public constructor(message?: string) { - super('object-to-zui', message) + public constructor(message?: string, path?: string) { + super('object-to-zui', message, path) } } // zui-to-json-schema-error export class ZuiToJSONSchemaError extends ZuiTransformError { - public constructor(message?: string) { - super('zui-to-json-schema', message) + public constructor(message?: string, path?: string) { + super('zui-to-json-schema', message, path) } } export class UnsupportedZuiToJSONSchemaError extends ZuiToJSONSchemaError { - public constructor(type: ZodNativeTypeName, { suggestedAlternative }: { suggestedAlternative?: string } = {}) { - super( - `Zod type ${type} cannot be transformed to JSON Schema.` + - (suggestedAlternative ? ` Suggested alternative: ${suggestedAlternative}` : '') - ) + public constructor( + type: ZodNativeTypeName, + path?: string, + { suggestedAlternative }: { suggestedAlternative?: string } = {} + ) { + const msg = suggestedAlternative + ? `Zod type ${type} cannot be transformed to JSON Schema. Suggested alternative: ${suggestedAlternative}` + : `Zod type ${type} cannot be transformed to JSON Schema.` + super(msg, path) } } export class UnsupportedZuiCheckToJSONSchemaError extends ZuiToJSONSchemaError { - public constructor({ zodType, checkKind }: { zodType: ZodNativeTypeName; checkKind: string }) { - super(`Zod check .${checkKind}() of type ${zodType} cannot be transformed to JSON Schema.`) - } -} - -export class UnsupportedJSONSchemaToZuiError extends JSONSchemaToZuiError { - public constructor(schema: JSONSchema7) { - super(`JSON Schema ${JSON.stringify(schema)} cannot be transformed to ZUI type.`) + public constructor(zodType: ZodNativeTypeName, checkKind: string, path?: string) { + super(`Zod check .${checkKind}() of type ${zodType} cannot be transformed to JSON Schema.`, path) } } // zui-to-typescript-schema-error export class ZuiToTypescriptSchemaError extends ZuiTransformError { - public constructor(message?: string) { - super('zui-to-typescript-schema', message) + public constructor(message?: string, path?: string) { + super('zui-to-typescript-schema', message, path) } } export class UnsupportedZuiToTypescriptSchemaError extends ZuiToTypescriptSchemaError { - public constructor(type: ZodNativeTypeName) { - super(`Zod type ${type} cannot be transformed to TypeScript schema.`) + public constructor(type: ZodNativeTypeName, path?: string) { + super(`Zod type ${type} cannot be transformed to TypeScript schema.`, path) } } // zui-to-typescript-type-error export class ZuiToTypescriptTypeError extends ZuiTransformError { - public constructor(message?: string) { - super('zui-to-typescript-type', message) + public constructor(message?: string, path?: string) { + super('zui-to-typescript-type', message, path) } } export class UnsupportedZuiToTypescriptTypeError extends ZuiToTypescriptTypeError { - public constructor(type: ZodNativeTypeName) { - super(`Zod type ${type} cannot be transformed to TypeScript type.`) + public constructor(type: ZodNativeTypeName, path?: string) { + super(`Zod type ${type} cannot be transformed to TypeScript type.`, path) } } - export class UntitledDeclarationError extends ZuiToTypescriptTypeError { - public constructor() { - super('Schema must have a title to be transformed to a TypeScript type with a declaration.') + public constructor(path?: string) { + super('Schema must have a title to be transformed to a TypeScript type with a declaration.', path) } } - export class UnrepresentableGenericError extends ZuiToTypescriptTypeError { - public constructor() { - super('ZodRef can only be transformed to a TypeScript type with a "type" declaration.') + public constructor(path?: string) { + super('ZodRef can only be transformed to a TypeScript type with a "type" declaration.', path) } } diff --git a/packages/zui/src/transforms/zui-to-json-schema/index.test.ts b/packages/zui/src/transforms/zui-to-json-schema/index.test.ts index b1d0471413d..dab73a9497c 100644 --- a/packages/zui/src/transforms/zui-to-json-schema/index.test.ts +++ b/packages/zui/src/transforms/zui-to-json-schema/index.test.ts @@ -414,4 +414,23 @@ describe('zuiToJSONSchemaNext', () => { const schema = toJSONSchema(z.ref('foo')) expect(schema).toEqual({ $ref: 'foo' }) }) + + test('should show complete path section in error message', () => { + try { + toJSONSchema(z.object({ foo: z.object({ bar: z.tuple([z.number(), z.void()]) }) })) + expect.fail('should have thrown') + } catch (e) { + expect(e instanceof Error && e.message).toContain('#.foo.bar[1]') + } + }) + + test('should expose path as a property on the error', () => { + try { + toJSONSchema(z.object({ foo: z.object({ bar: z.tuple([z.number(), z.void()]) }) })) + expect.fail('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(errs.ZuiTransformError) + expect((e as errs.ZuiTransformError).path).toBe('#.foo.bar[1]') + } + }) }) diff --git a/packages/zui/src/transforms/zui-to-json-schema/index.ts b/packages/zui/src/transforms/zui-to-json-schema/index.ts index 05d2e18db2a..9d266b660b5 100644 --- a/packages/zui/src/transforms/zui-to-json-schema/index.ts +++ b/packages/zui/src/transforms/zui-to-json-schema/index.ts @@ -1,4 +1,5 @@ import * as utils from '../../utils' +import { PropertyPath } from '../../utils/property-path-utils' import * as z from '../../z' import * as err from '../common/errors' import * as json from '../common/json-schema' @@ -48,21 +49,29 @@ const DEFAULT_OPTIONS: JSONSchemaGenerationOptions = { * @returns ZUI flavored JSON schema */ export function toJSONSchema(schema: z.ZodType, options: Partial = {}): json.Schema { + return _toJSONSchema(schema, options, new PropertyPath()) +} + +function _toJSONSchema( + schema: z.ZodType, + options: Partial = {}, + path: PropertyPath +): json.Schema { const opts = { ...DEFAULT_OPTIONS, ...options } const s = schema as z.ZodNativeType switch (s.typeName) { case 'ZodString': - return zodStringToJsonString(s) satisfies json.StringSchema + return zodStringToJsonString(s, path) satisfies json.StringSchema case 'ZodNumber': return zodNumberToJsonNumber(s) satisfies json.NumberSchema case 'ZodNaN': - throw new err.UnsupportedZuiToJSONSchemaError('ZodNaN') + throw new err.UnsupportedZuiToJSONSchemaError('ZodNaN', path.toString()) case 'ZodBigInt': - throw new err.UnsupportedZuiToJSONSchemaError('ZodBigInt', { + throw new err.UnsupportedZuiToJSONSchemaError('ZodBigInt', path.toString(), { suggestedAlternative: 'serialize bigint to string', }) @@ -74,7 +83,7 @@ export function toJSONSchema(schema: z.ZodType, options: Partial toJSONSchema(i, opts)) satisfies json.ArraySchema + return zodArrayToJsonArray(s, (i) => + _toJSONSchema(i, opts, path.withIndexType('number')) + ) satisfies json.ArraySchema case 'ZodObject': const shape = Object.entries(s.shape) @@ -115,14 +126,16 @@ export function toJSONSchema(schema: z.ZodType, options: Partial key) : undefined const properties = shape .map(([key, value]) => [key, value.mandatory()] satisfies [string, z.ZodType]) - .map(([key, value]) => [key, toJSONSchema(value, opts)] satisfies [string, json.Schema]) + .map( + ([key, value]) => [key, _toJSONSchema(value, opts, path.appendSection(key))] satisfies [string, json.Schema] + ) return { type: 'object', description: s.description, properties: Object.fromEntries(properties), required, - additionalProperties: additionalPropertiesSchema(s._def, opts), + additionalProperties: additionalPropertiesSchema(s._def, opts, path), 'x-zui': s._def['x-zui'], } satisfies json.ObjectSchema @@ -130,13 +143,13 @@ export function toJSONSchema(schema: z.ZodType, options: Partial toJSONSchema(option, opts)), + oneOf: s.options.map((option, index) => _toJSONSchema(option, opts, path.withIndexType('number', index))), 'x-zui': s._def['x-zui'], } satisfies json.UnionSchema } return { description: s.description, - anyOf: s.options.map((option) => toJSONSchema(option, opts)), + anyOf: s.options.map((option, index) => _toJSONSchema(option, opts, path.withIndexType('number', index))), 'x-zui': s._def['x-zui'], } satisfies json.UnionSchema @@ -145,7 +158,7 @@ export function toJSONSchema(schema: z.ZodType, options: Partial toJSONSchema(option, opts)), + oneOf: s.options.map((option, index) => _toJSONSchema(option, opts, path.withIndexType('number', index))), discriminator, 'x-zui': { ...s._def['x-zui'], @@ -155,7 +168,7 @@ export function toJSONSchema(schema: z.ZodType, options: Partial toJSONSchema(option, opts)), + anyOf: s.options.map((option, index) => _toJSONSchema(option, opts, path.withIndexType('number', index))), 'x-zui': { ...s._def['x-zui'], def: { typeName: 'ZodDiscriminatedUnion', discriminator: s.discriminator }, @@ -163,8 +176,8 @@ export function toJSONSchema(schema: z.ZodType, options: Partial toJSONSchema(i, opts)) satisfies json.TupleSchema - - case 'ZodRecord': + return zodTupleToJsonTuple(s, (i, p) => _toJSONSchema(i, opts, p), path) satisfies json.TupleSchema + + case 'ZodRecord': { + const keyType = s._def.keyType + const recordPath = z.is.zuiString(keyType) + ? path.withIndexType('string') + : z.is.zuiNumber(keyType) + ? path.withIndexType('number') + : path.withIndexType('any') return { type: 'object', description: s.description, - additionalProperties: toJSONSchema(s._def.valueType, opts), + additionalProperties: _toJSONSchema(s._def.valueType, opts, recordPath), 'x-zui': s._def['x-zui'], } satisfies json.RecordSchema + } case 'ZodMap': - throw new err.UnsupportedZuiToJSONSchemaError('ZodMap') + throw new err.UnsupportedZuiToJSONSchemaError('ZodMap', path.toString()) case 'ZodSet': - return zodSetToJsonSet(s, (i) => toJSONSchema(i, opts)) satisfies json.SetSchema + return zodSetToJsonSet(s, (i) => _toJSONSchema(i, opts, path.withIndexType('number'))) satisfies json.SetSchema case 'ZodFunction': - throw new err.UnsupportedZuiToJSONSchemaError('ZodFunction') + throw new err.UnsupportedZuiToJSONSchemaError('ZodFunction', path.toString()) case 'ZodLazy': - throw new err.UnsupportedZuiToJSONSchemaError('ZodLazy') + throw new err.UnsupportedZuiToJSONSchemaError('ZodLazy', path.toString()) case 'ZodLiteral': if (typeof s.value === 'string') { @@ -240,7 +260,7 @@ export function toJSONSchema(schema: z.ZodType, options: Partial ({ const additionalPropertiesSchema = ( def: z.ZodObjectDef, - opts: Partial + opts: Partial, + path: PropertyPath ): NonNullable => { if (def.unknownKeys === 'passthrough') { return true @@ -350,5 +371,5 @@ const additionalPropertiesSchema = ( return false } - return toJSONSchema(def.unknownKeys, opts) + return _toJSONSchema(def.unknownKeys, opts, path.withIndexType('string')) } diff --git a/packages/zui/src/transforms/zui-to-json-schema/type-processors/string.ts b/packages/zui/src/transforms/zui-to-json-schema/type-processors/string.ts index d73e15ae3fa..e61d3cc96cd 100644 --- a/packages/zui/src/transforms/zui-to-json-schema/type-processors/string.ts +++ b/packages/zui/src/transforms/zui-to-json-schema/type-processors/string.ts @@ -1,4 +1,5 @@ import { generateDatetimeRegex } from '../../../utils/datestring-utils' +import { PropertyPath } from '../../../utils/property-path-utils' import * as z from '../../../z' import { regexUtils } from '../../common' import * as errors from '../../common/errors' @@ -7,7 +8,7 @@ import { zodPatterns } from '../../zui-to-json-schema-legacy/parsers/string' const { zuiKey } = z -export const zodStringToJsonString = (zodString: z.ZodString): json.StringSchema => { +export const zodStringToJsonString = (zodString: z.ZodString, path: PropertyPath): json.StringSchema => { const schema: json.StringSchema = { type: 'string', description: zodString.description, @@ -84,10 +85,7 @@ export const zodStringToJsonString = (zodString: z.ZodString): json.StringSchema schema.maxLength = Math.max(0, check.value) break default: - throw new errors.UnsupportedZuiCheckToJSONSchemaError({ - zodType: 'ZodString', - checkKind: check.kind, - }) + throw new errors.UnsupportedZuiCheckToJSONSchemaError('ZodString', check.kind, path.toString()) } } diff --git a/packages/zui/src/transforms/zui-to-json-schema/type-processors/tuple.ts b/packages/zui/src/transforms/zui-to-json-schema/type-processors/tuple.ts index c819463fdb7..b51062814f6 100644 --- a/packages/zui/src/transforms/zui-to-json-schema/type-processors/tuple.ts +++ b/packages/zui/src/transforms/zui-to-json-schema/type-processors/tuple.ts @@ -1,3 +1,4 @@ +import { PropertyPath } from '../../../utils/property-path-utils' import * as z from '../../../z' import * as json from '../../common/json-schema' @@ -5,12 +6,13 @@ const { zuiKey } = z export const zodTupleToJsonTuple = ( zodTuple: z.ZodTuple, - toSchema: (x: z.ZodType) => json.Schema + toSchema: (x: z.ZodType, path: PropertyPath) => json.Schema, + path: PropertyPath ): json.TupleSchema => { const schema: json.TupleSchema = { type: 'array', description: zodTuple.description, - items: zodTuple._def.items.map((item) => toSchema(item)), + items: zodTuple._def.items.map((item, index) => toSchema(item, path.withIndexType('number', index))), } if (zodTuple._def[zuiKey]) { @@ -18,7 +20,7 @@ export const zodTupleToJsonTuple = ( } if (zodTuple._def.rest) { - schema.additionalItems = toSchema(zodTuple._def.rest) + schema.additionalItems = toSchema(zodTuple._def.rest, path.withIndexType('number')) } return schema diff --git a/packages/zui/src/utils/property-path-utils.test.ts b/packages/zui/src/utils/property-path-utils.test.ts new file mode 100644 index 00000000000..2730c9c3158 --- /dev/null +++ b/packages/zui/src/utils/property-path-utils.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { PropertyPath } from './property-path-utils' + +describe.concurrent('PropertyPath', () => { + it('returns only # for an empty path', () => { + expect(new PropertyPath().toString()).toBe('#') + }) + + it('renders a name-only section', () => { + expect(new PropertyPath().appendSection('foo').toString()).toBe('#.foo') + }) + + describe.concurrent('withIndexType', () => { + it('adds a number index without a value', () => { + const path = new PropertyPath().appendSection('foo').withIndexType('number') + expect(path.toString()).toBe('#.foo[number]') + }) + + it('adds a number index with a value', () => { + const path = new PropertyPath().appendSection('foo').withIndexType('number', 3) + expect(path.toString()).toBe('#.foo[3]') + }) + + it('adds a string index without a value', () => { + const path = new PropertyPath().appendSection('foo').withIndexType('string') + expect(path.toString()).toBe('#.foo[string]') + }) + + it('adds a string index with a value', () => { + const path = new PropertyPath().appendSection('foo').withIndexType('string', 'foo') + expect(path.toString()).toBe('#.foo[foo]') + }) + + it('adds an any index', () => { + const path = new PropertyPath().appendSection('foo').withIndexType('any') + expect(path.toString()).toBe('#.foo[*]') + }) + + it('stacks multiple indices on the same section', () => { + const path = new PropertyPath().appendSection('foo').withIndexType('number', 2).withIndexType('string', 'col') + expect(path.toString()).toBe('#.foo[2][col]') + }) + + it('applies different index types on consecutive sections', () => { + const path = new PropertyPath() + .appendSection('foo') + .withIndexType('number', 2) + .appendSection('bar') + .withIndexType('string', 'baz') + expect(path.toString()).toBe('#.foo[2].bar[baz]') + }) + }) + + describe.concurrent('withPrefix', () => { + it('prepends the prefix followed by a space before the #', () => { + const path = new PropertyPath().appendSection('foo').withPrefix('keyOf') + expect(path.toString()).toBe('keyOf #.foo') + }) + + it('does not mutate the original', () => { + const original = new PropertyPath().appendSection('foo') + const prefixed: PropertyPath = original.withPrefix('keyOf') + expect(original.toString()).toBe('#.foo') + expect(prefixed.toString()).toBe('keyOf #.foo') + }) + + it('prefix is preserved through appendSection', () => { + const path = new PropertyPath().appendSection('foo').withPrefix('keyOf').appendSection('bar') + expect(path.toString()).toBe('keyOf #.foo.bar') + }) + + it('prefix is preserved through withIndexType', () => { + const path = new PropertyPath().appendSection('foo').withPrefix('keyOf').withIndexType('number', 1) + expect(path.toString()).toBe('keyOf #.foo[1]') + }) + }) + + describe.concurrent('appendSection', () => { + it('does not mutate the original', () => { + const original = new PropertyPath() + const next = original.appendSection('foo') + expect(original.toString()).toBe('#') + expect(next.toString()).toBe('#.foo') + }) + + it('chains multiple appends', () => { + const path = new PropertyPath().appendSection('foo').appendSection('bar') + expect(path.toString()).toBe('#.foo.bar') + }) + }) +}) diff --git a/packages/zui/src/utils/property-path-utils.ts b/packages/zui/src/utils/property-path-utils.ts new file mode 100644 index 00000000000..26e26352ec0 --- /dev/null +++ b/packages/zui/src/utils/property-path-utils.ts @@ -0,0 +1,55 @@ +export type KeySectionIndex = { + type: 'key' + value: string +} + +export type NumberSectionIndex = { + type: 'number' + value?: number +} + +export type StringSectionIndex = { + type: 'string' + value?: string +} + +export type AnySectionIndex = { + type: 'any' +} + +export type PathSection = KeySectionIndex | NumberSectionIndex | StringSectionIndex | AnySectionIndex + +export class PropertyPath { + private readonly _sections: PathSection[] + private readonly _prefix: string + + constructor(sections: PathSection[] = [], prefix: string = '') { + this._sections = sections + this._prefix = prefix + } + + appendSection(name: string): PropertyPath { + return new PropertyPath([...this._sections, { type: 'key', value: name }], this._prefix) + } + + withIndexType(type: 'number', value?: number): PropertyPath + withIndexType(type: 'string', value?: string): PropertyPath + withIndexType(type: 'any'): PropertyPath + withIndexType(type: 'number' | 'string' | 'any', value?: number | string): PropertyPath { + return new PropertyPath([...this._sections, { type, value } as PathSection], this._prefix) + } + + withPrefix(prefix: string): PropertyPath { + return new PropertyPath(this._sections, prefix + ' ') + } + + toString(): string { + return `${this._prefix}#${this._sections + .map((section) => { + if (section.type === 'key') return `.${section.value}` + if (section.type === 'any') return '[*]' + return `[${section.value ?? section.type}]` + }) + .join('')}` + } +}