diff --git a/packages/core/src/json-schema.ts b/packages/core/src/json-schema.ts index 98913a7f0a..486a3ac76e 100644 --- a/packages/core/src/json-schema.ts +++ b/packages/core/src/json-schema.ts @@ -9,6 +9,9 @@ export type Schema = // | BaseSchema; export interface BaseSchema { + /** A special key used as an intermediate representation of extends-style relationships. Removed in the omit stage. */ + _ref?: BaseSchema; + type?: string | undefined; $id?: string | undefined; id?: string | undefined; diff --git a/packages/core/src/registries.ts b/packages/core/src/registries.ts index cacdbc439b..28c0eaaf28 100644 --- a/packages/core/src/registries.ts +++ b/packages/core/src/registries.ts @@ -61,28 +61,20 @@ export interface JSONSchemaMeta { [k: string]: unknown; } -export class $ZodJSONSchemaRegistry< - Meta extends JSONSchemaMeta = JSONSchemaMeta, - Schema extends $ZodType = $ZodType, -> extends $ZodRegistry { - toJSONSchema(_schema: Schema): object { - return {}; - } -} +// export class $ZodJSONSchemaRegistry< +// Meta extends JSONSchemaMeta = JSONSchemaMeta, +// Schema extends $ZodType = $ZodType, +// > extends $ZodRegistry { +// toJSONSchema(_schema: Schema): object { +// return {}; +// } +// } export interface GlobalMeta extends JSONSchemaMeta {} -export const globalRegistry: $ZodJSONSchemaRegistry = - /*@__PURE__*/ new $ZodJSONSchemaRegistry(); - // registries export function registry(): $ZodRegistry { return new $ZodRegistry(); } -export function jsonSchemaRegistry< - T extends JSONSchemaMeta = JSONSchemaMeta, - S extends $ZodType = $ZodType, ->(): $ZodJSONSchemaRegistry { - return new $ZodJSONSchemaRegistry(); -} +export const globalRegistry: $ZodRegistry = /*@__PURE__*/ registry(); diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 176d0959b4..64e9215616 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -130,7 +130,7 @@ export interface $ZodTypeInternals extends $Zo /** The constructor function of this schema. */ constr: new ( def: any - ) => any; + ) => $ZodType; /** A catchall object for computed metadata related to this schema. Commonly modified by checks using `onattach`. */ computed: Record; @@ -140,6 +140,9 @@ export interface $ZodTypeInternals extends $Zo /** An optional method used to override `toJSONSchema` logic. */ toJSONSchema?: () => object; + + /** The parent of this schema. Only set during certain clone operations. */ + parent?: $ZodType | undefined; } export interface $ZodType { diff --git a/packages/core/src/to-json-schema.ts b/packages/core/src/to-json-schema.ts index 2aea3a9865..19f5daa28b 100644 --- a/packages/core/src/to-json-schema.ts +++ b/packages/core/src/to-json-schema.ts @@ -61,11 +61,16 @@ const formatMap: Partial> = { }; interface Seen { + /** JSON Schema result for this Zod schema */ schema: JSONSchema.BaseSchema; - cached: JSONSchema.BaseSchema; + /** A cached version of the schema that doesn't get overwritten during ref resolution */ + def?: JSONSchema.BaseSchema; + defId?: string | undefined; + /** Number of times this schema was encountered during traversal */ count: number; + /** Cycle path */ cycle?: (string | number)[] | undefined; - defId?: string | undefined; + // external?: string | undefined; } @@ -118,34 +123,31 @@ export class JSONSchemaGenerator { } // initialize - const result: Seen = { schema: {}, cached: {}, count: 1, cycle: undefined }; + const result: Seen = { schema: {}, count: 1, cycle: undefined }; this.seen.set(schema, result); + // if(schema._zod.parent){ + // // schema was cloned from another schema + // result. + // } + if (schema._zod.toJSONSchema) { // custom method overrides default behavior result.schema = schema._zod.toJSONSchema() as any; // return } - const _json = result.schema; - // check if external // const ext = this.external?.registry.get(schema)?.id; // if (ext) { // result.external = ext; // } - // metadata - const meta = this.metadataRegistry.get(schema); - if (meta) { - Object.assign(_json, meta); - // schema exists in registry. add to $defs. - // if (meta.id) _json.id = meta.id; - // if (meta.description) _json.description = meta.description; - // if (meta.title) _json.title = meta.title; - // if (meta.examples) _json.examples = meta.examples; - // if (meta.example) _json.examples = [meta.example]; - } + // // metadata + // const meta = this.metadataRegistry.get(schema); + // if (meta) { + // Object.assign(_json, meta); + // } // const def = (schema as schemas.$ZodTypes)._zod.def; const params = { @@ -154,373 +156,391 @@ export class JSONSchemaGenerator { path: _params.path, }; - switch (def.type) { - case "string": { - const json: JSONSchema.StringSchema = _json as any; - json.type = "string"; - const { minimum, maximum, format, pattern, contentEncoding } = schema._zod.computed as { - minimum?: number; - maximum?: number; - format?: checks.$ZodStringFormats; - pattern?: RegExp; - contentEncoding?: string; - }; - if (minimum) json.minLength = minimum; - if (maximum) json.maxLength = maximum; - // custom pattern overrides format - if (format) { - json.format = formatMap[format] ?? format; - } - if (pattern) { - json.pattern = pattern.source; + if (schema._zod.parent) { + // schema was cloned from another schema + result.schema._ref = this.process(schema._zod.parent, params); + } else { + const _json = result.schema; + switch (def.type) { + case "string": { + const json: JSONSchema.StringSchema = _json as any; + json.type = "string"; + const { minimum, maximum, format, pattern, contentEncoding } = schema._zod.computed as { + minimum?: number; + maximum?: number; + format?: checks.$ZodStringFormats; + pattern?: RegExp; + contentEncoding?: string; + }; + if (minimum) json.minLength = minimum; + if (maximum) json.maxLength = maximum; + // custom pattern overrides format + if (format) { + json.format = formatMap[format] ?? format; + } + if (pattern) { + json.pattern = pattern.source; + } + if (contentEncoding) json.contentEncoding = contentEncoding; + + break; } - if (contentEncoding) json.contentEncoding = contentEncoding; + case "number": { + const json: JSONSchema.NumberSchema | JSONSchema.IntegerSchema = _json as any; + const { minimum, maximum, format, multipleOf, inclusive } = schema._zod.computed as { + minimum?: number; + maximum?: number; + format?: checks.$ZodNumberFormats; + multipleOf?: number; + inclusive?: boolean; + }; + if (format?.includes("int")) json.type = "integer"; + else json.type = "number"; + + if (minimum) { + if (inclusive) json.minimum = minimum; + else json.exclusiveMinimum = minimum; + } + if (maximum) { + if (inclusive) json.maximum = maximum; + else json.exclusiveMaximum = maximum; + } + if (multipleOf) json.multipleOf = multipleOf; - break; - } - case "number": { - const json: JSONSchema.NumberSchema | JSONSchema.IntegerSchema = _json as any; - const { minimum, maximum, format, multipleOf, inclusive } = schema._zod.computed as { - minimum?: number; - maximum?: number; - format?: checks.$ZodNumberFormats; - multipleOf?: number; - inclusive?: boolean; - }; - if (format?.includes("int")) json.type = "integer"; - else json.type = "number"; - - if (minimum) { - if (inclusive) json.minimum = minimum; - else json.exclusiveMinimum = minimum; - } - if (maximum) { - if (inclusive) json.maximum = maximum; - else json.exclusiveMaximum = maximum; - } - if (multipleOf) json.multipleOf = multipleOf; - - break; - } - case "boolean": { - const json = _json as JSONSchema.BooleanSchema; - json.type = "boolean"; - break; - } - case "bigint": { - if (this.unrepresentable === "throw") { - throw new Error("BigInt cannot be represented in JSON Schema"); + break; } - break; - } - case "symbol": { - if (this.unrepresentable === "throw") { - throw new Error("Symbols cannot be represented in JSON Schema"); + case "boolean": { + const json = _json as JSONSchema.BooleanSchema; + json.type = "boolean"; + break; } - break; - } - case "undefined": { - const json = _json as JSONSchema.NullSchema; - json.type = "null"; - break; - } - case "null": { - _json.type = "null"; - // const json = { type: "null" } as JSONSchema.NullSchema; - break; - } - case "any": { - break; - } - case "unknown": { - break; - } - case "never": { - _json.not = {}; - break; - } - case "void": { - if (this.unrepresentable === "throw") { - throw new Error("Void cannot be represented in JSON Schema"); + case "bigint": { + if (this.unrepresentable === "throw") { + throw new Error("BigInt cannot be represented in JSON Schema"); + } + break; } - break; - } - case "date": { - if (this.unrepresentable === "throw") { - throw new Error("Date cannot be represented in JSON Schema"); + case "symbol": { + if (this.unrepresentable === "throw") { + throw new Error("Symbols cannot be represented in JSON Schema"); + } + break; } - break; - } - case "array": { - const json: JSONSchema.ArraySchema = _json as any; - const { minimum, maximum } = schema._zod.computed as { - minimum?: number; - maximum?: number; - }; - if (minimum) json.minItems = minimum; - if (maximum) json.maxItems = maximum; - json.type = "array"; - json.items = this.process(def.element, { ...params, path: [...params.path, "items"] }); - break; - } - case "object": - case "interface": { - const json: JSONSchema.ObjectSchema = _json as any; - json.type = "object"; - json.properties = {}; - const shape = def.shape; // params.shapeCache.get(schema)!; - // if (!shape) { - // shape = def.shape; - // params.shapeCache.set(schema, shape); - // } - for (const key in shape) { - json.properties[key] = this.process(shape[key], { - ...params, - path: [...params.path, "properties", key], - }); + case "undefined": { + const json = _json as JSONSchema.NullSchema; + json.type = "null"; + break; + } + case "null": { + _json.type = "null"; + // const json = { type: "null" } as JSONSchema.NullSchema; + break; + } + case "any": { + break; + } + case "unknown": { + break; + } + case "never": { + _json.not = {}; + break; + } + case "void": { + if (this.unrepresentable === "throw") { + throw new Error("Void cannot be represented in JSON Schema"); + } + break; } + case "date": { + if (this.unrepresentable === "throw") { + throw new Error("Date cannot be represented in JSON Schema"); + } + break; + } + case "array": { + const json: JSONSchema.ArraySchema = _json as any; + const { minimum, maximum } = schema._zod.computed as { + minimum?: number; + maximum?: number; + }; + if (minimum) json.minItems = minimum; + if (maximum) json.maxItems = maximum; + json.type = "array"; + json.items = this.process(def.element, { ...params, path: [...params.path, "items"] }); + break; + } + case "object": + case "interface": { + const json: JSONSchema.ObjectSchema = _json as any; + json.type = "object"; + json.properties = {}; + const shape = def.shape; // params.shapeCache.get(schema)!; + // if (!shape) { + // shape = def.shape; + // params.shapeCache.set(schema, shape); + // } + for (const key in shape) { + json.properties[key] = this.process(shape[key], { + ...params, + path: [...params.path, "properties", key], + }); + } - // required keys - const allKeys = new Set(Object.keys(shape)); - const optionalKeys = new Set(def.optional); - const requiredKeys = new Set([...allKeys].filter((key) => !optionalKeys.has(key))); - json.required = Array.from(requiredKeys); + // required keys + const allKeys = new Set(Object.keys(shape)); + const optionalKeys = new Set(def.optional); + const requiredKeys = new Set([...allKeys].filter((key) => !optionalKeys.has(key))); + json.required = Array.from(requiredKeys); + + // catchall + if (def.catchall) { + json.additionalProperties = this.process(def.catchall, { + ...params, + path: [...params.path, "additionalProperties"], + }); + } - // catchall - if (def.catchall) { - json.additionalProperties = this.process(def.catchall, { - ...params, - path: [...params.path, "additionalProperties"], - }); + break; } - - break; - } - case "union": { - const json: JSONSchema.BaseSchema = _json as any; - json.anyOf = def.options.map((x, i) => - this.process(x, { - ...params, - path: [...params.path, "anyOf", i], - }) - ); - break; - } - case "intersection": { - const json: JSONSchema.BaseSchema = _json as any; - json.allOf = [ - this.process(def.left, { - ...params, - path: [...params.path, "allOf", 0], - }), - this.process(def.right, { - ...params, - path: [...params.path, "allOf", 1], - }), - ]; - break; - } - case "tuple": { - const json: JSONSchema.ArraySchema = _json as any; - json.type = "array"; - const prefixItems = def.items.map((x, i) => - this.process(x, { ...params, path: [...params.path, "prefixItems", i] }) - ); - if (this.target === "draft-2020-12") { - json.prefixItems = prefixItems; - } else { - json.items = prefixItems; - } - - if (def.rest) { - const rest = this.process(def.rest, { - ...params, - path: [...params.path, "items"], - }); + case "union": { + const json: JSONSchema.BaseSchema = _json as any; + json.anyOf = def.options.map((x, i) => + this.process(x, { + ...params, + path: [...params.path, "anyOf", i], + }) + ); + break; + } + case "intersection": { + const json: JSONSchema.BaseSchema = _json as any; + json.allOf = [ + this.process(def.left, { + ...params, + path: [...params.path, "allOf", 0], + }), + this.process(def.right, { + ...params, + path: [...params.path, "allOf", 1], + }), + ]; + break; + } + case "tuple": { + const json: JSONSchema.ArraySchema = _json as any; + json.type = "array"; + const prefixItems = def.items.map((x, i) => + this.process(x, { ...params, path: [...params.path, "prefixItems", i] }) + ); if (this.target === "draft-2020-12") { - json.items = rest; + json.prefixItems = prefixItems; } else { - json.additionalItems = rest; + json.items = prefixItems; + } + + if (def.rest) { + const rest = this.process(def.rest, { + ...params, + path: [...params.path, "items"], + }); + if (this.target === "draft-2020-12") { + json.items = rest; + } else { + json.additionalItems = rest; + } + } + + // additionalItems + if (def.rest) { + json.items = this.process(def.rest, { + ...params, + path: [...params.path, "items"], + }); } - } - // additionalItems - if (def.rest) { - json.items = this.process(def.rest, { + // length + const { minimum, maximum } = schema._zod.computed as { + minimum?: number; + maximum?: number; + }; + if (minimum) json.minItems = minimum; + if (maximum) json.maxItems = maximum; + break; + } + case "record": { + const json: JSONSchema.ObjectSchema = _json as any; + json.type = "object"; + json.propertyNames = this.process(def.keyType, { ...params, path: [...params.path, "propertyNames"] }); + json.additionalProperties = this.process(def.valueType, { ...params, - path: [...params.path, "items"], + path: [...params.path, "additionalProperties"], }); + break; } - - // length - const { minimum, maximum } = schema._zod.computed as { - minimum?: number; - maximum?: number; - }; - if (minimum) json.minItems = minimum; - if (maximum) json.maxItems = maximum; - break; - } - case "record": { - const json: JSONSchema.ObjectSchema = _json as any; - json.type = "object"; - json.propertyNames = this.process(def.keyType, { ...params, path: [...params.path, "propertyNames"] }); - json.additionalProperties = this.process(def.valueType, { - ...params, - path: [...params.path, "additionalProperties"], - }); - break; - } - case "map": { - if (this.unrepresentable === "throw") { - throw new Error("Map cannot be represented in JSON Schema"); + case "map": { + if (this.unrepresentable === "throw") { + throw new Error("Map cannot be represented in JSON Schema"); + } + break; } - break; - } - case "set": { - if (this.unrepresentable === "throw") { - throw new Error("Set cannot be represented in JSON Schema"); + case "set": { + if (this.unrepresentable === "throw") { + throw new Error("Set cannot be represented in JSON Schema"); + } + break; } - break; - } - case "enum": { - const json: JSONSchema.BaseSchema = _json as any; - json.enum = Object.values(def.entries); - break; - } - case "literal": { - const json: JSONSchema.BaseSchema = _json as any; - const vals: (string | number | boolean | null)[] = []; - for (const val of def.values) { - if (val === undefined) { - if (this.unrepresentable === "throw") { - throw new Error("Literal `undefined` cannot be represented in JSON Schema"); - } else { - // do not add to vals - } - } else if (typeof val === "bigint") { - if (this.unrepresentable === "throw") { - throw new Error("BigInt literals cannot be represented in JSON Schema"); + case "enum": { + const json: JSONSchema.BaseSchema = _json as any; + json.enum = Object.values(def.entries); + break; + } + case "literal": { + const json: JSONSchema.BaseSchema = _json as any; + const vals: (string | number | boolean | null)[] = []; + for (const val of def.values) { + if (val === undefined) { + if (this.unrepresentable === "throw") { + throw new Error("Literal `undefined` cannot be represented in JSON Schema"); + } else { + // do not add to vals + } + } else if (typeof val === "bigint") { + if (this.unrepresentable === "throw") { + throw new Error("BigInt literals cannot be represented in JSON Schema"); + } else { + vals.push(Number(val)); + } } else { - vals.push(Number(val)); + vals.push(val); } + } + if (vals.length === 0) { + // do nothing (an undefined literal was stripped) + } else if (vals.length === 1) { + const val = vals[0]; + json.const = val; } else { - vals.push(val); + json.enum = vals; } + break; } - if (vals.length === 0) { - // do nothing (an undefined literal was stripped) - } else if (vals.length === 1) { - const val = vals[0]; - json.const = val; - } else { - json.enum = vals; - } - break; - } - case "file": { - if (this.unrepresentable === "throw") { - throw new Error("File cannot be represented in JSON Schema"); + case "file": { + if (this.unrepresentable === "throw") { + throw new Error("File cannot be represented in JSON Schema"); + } + break; } - break; - } - case "transform": { - if (this.unrepresentable === "throw") { - throw new Error("Transforms cannot be represented in JSON Schema"); + case "transform": { + if (this.unrepresentable === "throw") { + throw new Error("Transforms cannot be represented in JSON Schema"); + } + break; } - break; - } - case "nullable": { - const inner = this.process(def.innerType, params); - _json.anyOf = [inner, { type: "null" }]; - break; - } - case "nonoptional": { - const inner = this.process(def.innerType, params); - Object.assign(_json, inner); - break; - } - case "success": { - const json = _json as JSONSchema.BooleanSchema; - json.type = "boolean"; - break; - } - case "default": { - const inner = this.process(def.innerType, params); - Object.assign(_json, inner); - _json.default = def.defaultValue(); - break; - } - case "catch": { - // use conditionals - const inner = this.process(def.innerType, params); - Object.assign(_json, inner); - let catchValue: any; - try { - catchValue = def.catchValue(undefined as any); - } catch { - throw new Error("Dynamic catch values are not supported in JSON Schema"); - } - _json.default = catchValue; - break; - } - case "nan": { - if (this.unrepresentable === "throw") { - throw new Error("NaN cannot be represented in JSON Schema"); + case "nullable": { + const inner = this.process(def.innerType, params); + _json.anyOf = [inner, { type: "null" }]; + break; } - break; - } - case "pipe": { - const innerType = this.io === "input" ? def.in : def.out; - const inner = this.process(innerType, params); - result.schema = inner; + case "nonoptional": { + const inner = this.process(def.innerType, params); + Object.assign(_json, inner); + break; + } + case "success": { + const json = _json as JSONSchema.BooleanSchema; + json.type = "boolean"; + break; + } + case "default": { + const inner = this.process(def.innerType, params); + Object.assign(_json, inner); + _json.default = def.defaultValue(); + break; + } + case "catch": { + // use conditionals + const inner = this.process(def.innerType, params); + Object.assign(_json, inner); + let catchValue: any; + try { + catchValue = def.catchValue(undefined as any); + } catch { + throw new Error("Dynamic catch values are not supported in JSON Schema"); + } + _json.default = catchValue; + break; + } + case "nan": { + if (this.unrepresentable === "throw") { + throw new Error("NaN cannot be represented in JSON Schema"); + } + break; + } + case "pipe": { + const innerType = this.io === "input" ? def.in : def.out; + const inner = this.process(innerType, params); + // result.schema = inner; + _json._ref = inner; - break; - } - case "readonly": { - const inner = this.process(def.innerType, params); - Object.assign(_json, inner); - // _json.allOf = [inner]; - _json.readOnly = true; - break; - } - case "template_literal": { - const json = _json as JSONSchema.StringSchema; - const pattern = schema._zod.pattern; - if (!pattern) throw new Error("Pattern not found in template literal"); - json.type = "string"; - json.pattern = pattern.source; - break; - } - case "promise": { - const inner = this.process(def.innerType, params); - result.schema = inner; - break; - } - // passthrough types - case "optional": { - const inner = this.process(def.innerType, params); - result.schema = inner; - break; - } - case "lazy": { - const innerType = (schema as schemas.$ZodLazy)._zod._getter; - const inner = this.process(innerType, params); - result.schema = inner; - break; - } - case "custom": { - if (this.unrepresentable === "throw") { - throw new Error("Custom types cannot be represented in JSON Schema"); + break; + } + case "readonly": { + _json._ref = this.process(def.innerType, params); + _json.readOnly = true; + // const inner = this.process(def.innerType, params); + // Object.assign(_json, inner); + // _json.allOf = [inner]; + // result.extra.readOnly = true; + // _json.readOnly = true; + break; + } + case "template_literal": { + const json = _json as JSONSchema.StringSchema; + const pattern = schema._zod.pattern; + if (!pattern) throw new Error("Pattern not found in template literal"); + json.type = "string"; + json.pattern = pattern.source; + break; + } + case "promise": { + const inner = this.process(def.innerType, params); + // result.schema = inner; + _json._ref = inner; + break; + } + // passthrough types + case "optional": { + // result.schema = this.process(def.innerType, params); + _json._ref = this.process(def.innerType, params); + break; + } + case "lazy": { + const innerType = (schema as schemas.$ZodLazy)._zod._getter; + const inner = this.process(innerType, params); + // result.schema = inner; + _json._ref = inner; + break; + } + case "custom": { + if (this.unrepresentable === "throw") { + throw new Error("Custom types cannot be represented in JSON Schema"); + } + break; + } + default: { + def satisfies never; } - break; - } - default: { - def satisfies never; } } + // metadata + const meta = this.metadataRegistry.get(schema); + if (meta) { + Object.assign(result.schema, meta); + } + // pulling fresh from this.seen in case it was overwritten const _result = this.seen.get(schema)!; @@ -529,7 +549,7 @@ export class JSONSchemaGenerator { jsonSchema: _result.schema, }); - Object.assign(_result.cached, _result.schema); + // Object.assign(_result.cached, _result.schema); return _result.schema; } @@ -543,76 +563,90 @@ export class JSONSchemaGenerator { } satisfies EmitParams; // iterate over seen map - const result: JSONSchema.BaseSchema = {}; - const defs: JSONSchema.BaseSchema["$defs"] = params.external?.defs ?? {}; - const seen = this.seen.get(schema); - if (!seen) throw new Error("Unprocessed schema. This is a bug in Zod."); - Object.assign(result, seen.cached); + // const result: JSONSchema.BaseSchema = {}; + // const defs: JSONSchema.BaseSchema["$defs"] = params.external?.defs ?? {}; + const root = this.seen.get(schema); + + if (!root) throw new Error("Unprocessed schema. This is a bug in Zod."); + + // initialize result with root schema fields + // Object.assign(result, seen.cached); const makeURI = (entry: [schemas.$ZodType, Seen]): { ref: string; defId?: string } => { // comparing the seen objects because sometimes // multiple schemas map to the same seen object. // e.g. lazy - if (entry[1] === seen) { - return { ref: "#" }; - } // external is configured const defsSegment = this.target === "draft-2020-12" ? "$defs" : "definitions"; if (params.external) { const externalId = params.external.registry.get(entry[0])?.id; // ?? "__shared";// `__schema${this.counter++}`; + + // check if schema is in the external registry if (externalId) return { ref: params.external.uri(externalId) }; + + // otherwise, add to __shared const id = entry[1].defId ?? entry[1].schema.id ?? `schema${this.counter++}`; entry[1].defId = id; return { defId: id, ref: `${params.external.uri("__shared")}#/${defsSegment}/${id}` }; } + if (entry[1] === root) { + return { ref: "#" }; + } + // self-contained schema const uriPrefix = `#`; const defUriPrefix = `${uriPrefix}/${defsSegment}/`; const defId = entry[1].schema.id ?? `__schema${this.counter++}`; - return { defId, ref: defUriPrefix + defId }; }; + const extractToDef = (entry: [schemas.$ZodType, Seen]): void => { + if (entry[1].schema.$ref) { + // throw new Error("Already extracted"); + return; + } + const seen = entry[1]; + const { ref, defId } = makeURI(entry); + // defId won't be set if the schema is a reference to an external schema + + seen.def = { ...seen.schema }; + if (defId) seen.defId = defId; + // wipe away all properties except $ref + schemaToRef(seen, ref); + }; + + // extract schemas into $defs for (const entry of this.seen.entries()) { const seen = entry[1]; + + // convert root schema to # $ref + // also prevents root schema from being extracted if (schema === entry[0]) { - schemaToRef({ - schema: seen.schema, - ref: "#", - }); + // do not copy to defs...this is the root schema + extractToDef(entry); continue; } - // external + // extract schemas that are in the external registry if (params.external) { const ext = params.external.registry.get(entry[0])?.id; if (schema !== entry[0] && ext) { - // does not write to defs - const { ref, defId } = makeURI(entry); // params.external.uri(ext); - if (defId) defs[defId] = { ...seen.cached }; - schemaToRef({ - schema: seen.schema, - ref, - }); + extractToDef(entry); continue; } } - // handle schemas with `id` + // extract schemas with `id` meta const id = this.metadataRegistry.get(entry[0])?.id; if (id) { - const { ref, defId } = makeURI(entry); - if (defId) defs[defId] = { ...seen.cached }; - schemaToRef({ - schema: seen.schema, - ref, - }); + extractToDef(entry); + continue; } - // handle cycles + // break cycles if (seen.cycle) { if (params.cycles === "throw") { throw new Error( @@ -621,31 +655,37 @@ export class JSONSchemaGenerator { '\n\nSet the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs.' ); } else if (params.cycles === "ref") { - const { ref, defId } = makeURI(entry); // schema === entry[0] ? "#" : defUriPrefix + id; - if (defId) defs[defId] = { ...seen.cached }; - schemaToRef({ - schema: seen.schema, - ref, - }); + extractToDef(entry); } continue; } - // handle reused schemas + // extract reused schemas if (seen.count > 1) { if (params.reused === "ref") { - const { ref, defId } = makeURI(entry); - if (defId) defs[defId] = { ...seen.cached }; - schemaToRef({ - schema: seen.schema, - ref, - }); + extractToDef(entry); // biome-ignore lint: continue; } } } + for (const entry of this.seen.entries()) { + const seen = entry[1]; + flattenRef(seen.schema); + if (seen.def) flattenRef(seen.def); + } + + const result = { ...root.def }; + const defs: JSONSchema.BaseSchema["$defs"] = params.external?.defs ?? {}; + for (const entry of this.seen.entries()) { + const seen = entry[1]; + if (seen.def && seen.defId) { + defs[seen.defId] = seen.def; + } + } + + // set definitions in result if (!params.external && Object.keys(defs).length > 0) { if (this.target === "draft-2020-12") { result.$defs = defs; @@ -660,17 +700,23 @@ export class JSONSchemaGenerator { // though the seen map is shared return JSON.parse(JSON.stringify(result)); } catch (_err) { - console.log(_err); throw new Error("Error converting schema to JSON."); } } } -function schemaToRef(params: { - schema: JSONSchema.BaseSchema; - ref: string; -}) { - const { schema, ref } = params; +// flatten _refs +const flattenRef = (schema: JSONSchema.BaseSchema) => { + const _schema = { ...schema }; + if (schema._ref) flattenRef(schema._ref); + const ref = schema._ref; + Object.assign(schema, ref); + Object.assign(schema, _schema); // this is to prevent mutation of the original schema + delete schema._ref; +}; + +function schemaToRef(seen: Seen, ref: string) { + const schema = seen.schema; for (const key in schema) { delete schema[key]; schema.$ref = ref; @@ -726,6 +772,5 @@ export function toJSONSchema( const gen = new JSONSchemaGenerator(_params); gen.process(input); - // console.log(gen.seen); return gen.emit(input, _params); } diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 2e34755ea2..3d7e5d83a9 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -395,8 +395,10 @@ export function escapeRegex(str: string): string { } // zod-specific utils -export function clone(inst: T, def: T["_zod"]["def"]): T { - return new inst._zod.constr(def); +export function clone(inst: T, def?: T["_zod"]["def"]): T { + const cl = new inst._zod.constr(def ?? inst._zod.def); + if (!def) cl._zod.parent = inst; + return cl as any; } export type Params< diff --git a/packages/docs/content/error-customization.mdx b/packages/docs/content/error-customization.mdx index f13e683ea4..5795950067 100644 --- a/packages/docs/content/error-customization.mdx +++ b/packages/docs/content/error-customization.mdx @@ -382,6 +382,7 @@ Below is a quick reference for determining error precedence: if multiple error c z.config({ customError: (iss) => "My custom error" }); + ``` 4. **Locale error map** — A custom error map passed into `z.config()`. diff --git a/packages/mini/src/schemas.ts b/packages/mini/src/schemas.ts index 4f5cfe63e4..ad829375f9 100644 --- a/packages/mini/src/schemas.ts +++ b/packages/mini/src/schemas.ts @@ -55,7 +55,7 @@ export const ZodMiniType: core.$constructor = /*@__PURE__*/ core.$c ], }); }; - inst.clone = (_def) => core.clone(inst, _def ?? def); + inst.clone = (_def) => core.clone(inst, _def); inst.brand = () => inst as any; inst.register = ((reg: any, meta: any) => { reg.add(inst, meta); diff --git a/packages/zod/src/schemas.ts b/packages/zod/src/schemas.ts index 9e2854ff8e..1811bfc9cf 100644 --- a/packages/zod/src/schemas.ts +++ b/packages/zod/src/schemas.ts @@ -113,7 +113,7 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct ], }); }; - inst.clone = (_def) => core.clone(inst, _def ?? def); + inst.clone = (_def) => core.clone(inst, _def); inst.brand = () => inst as any; inst.register = ((reg: any, meta: any) => { reg.add(inst, meta); @@ -150,8 +150,8 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct // meta inst.describe = (description) => { const cl = inst.clone(); - const meta = core.globalRegistry.get(inst) ?? {}; - meta.description = description; + const meta = { ...(core.globalRegistry.get(inst) ?? {}), description }; + delete meta.id; // do not inherit core.globalRegistry.add(cl, meta); return cl; }; diff --git a/packages/zod/tests/json-schema.test.ts b/packages/zod/tests/json-schema.test.ts index 67b3fe7179..103473a9ad 100644 --- a/packages/zod/tests/json-schema.test.ts +++ b/packages/zod/tests/json-schema.test.ts @@ -1029,3 +1029,301 @@ test("unrepresentable literal values are ignored", () => { const c = z.toJSONSchema(z.literal([undefined]), { unrepresentable: "any" }); expect(c).toMatchInlineSnapshot(`{}`); }); + +test("describe with id", () => { + const jobId = z.string().meta({ id: "jobId" }); + + const a = z.toJSONSchema( + z.object({ + current: jobId.describe("Current job"), + previous: jobId.describe("Previous job"), + }) + ); + expect(a).toMatchInlineSnapshot(` + { + "$defs": { + "jobId": { + "id": "jobId", + "type": "string", + }, + }, + "properties": { + "current": { + "$ref": "#/$defs/jobId", + "description": "Current job", + }, + "previous": { + "$ref": "#/$defs/jobId", + "description": "Previous job", + }, + }, + "required": [ + "current", + "previous", + ], + "type": "object", + } + `); +}); + +test("overwrite id", () => { + const jobId = z.string().meta({ id: "aaa" }); + + const a = z.toJSONSchema( + z.object({ + current: jobId, + previous: jobId.meta({ id: "bbb" }), + }) + ); + expect(a).toMatchInlineSnapshot(` + { + "$defs": { + "aaa": { + "id": "aaa", + "type": "string", + }, + "bbb": { + "$ref": "#/$defs/aaa", + "id": "bbb", + }, + }, + "properties": { + "current": { + "$ref": "#/$defs/aaa", + }, + "previous": { + "$ref": "#/$defs/bbb", + }, + }, + "required": [ + "current", + "previous", + ], + "type": "object", + } + `); + + const b = z.toJSONSchema( + z.object({ + current: jobId, + previous: jobId.meta({ id: "ccc" }), + }), + { + reused: "ref", + } + ); + expect(b).toMatchInlineSnapshot(` + { + "$defs": { + "aaa": { + "id": "aaa", + "type": "string", + }, + "ccc": { + "$ref": "#/$defs/aaa", + "id": "ccc", + }, + }, + "properties": { + "current": { + "$ref": "#/$defs/aaa", + }, + "previous": { + "$ref": "#/$defs/ccc", + }, + }, + "required": [ + "current", + "previous", + ], + "type": "object", + } + `); +}); + +test("overwrite descriptions", () => { + const field = z.string().describe("a").describe("b").describe("c"); + + const a = z.toJSONSchema( + z.object({ + d: field.describe("d"), + e: field.describe("e"), + }) + ); + expect(a).toMatchInlineSnapshot(` + { + "properties": { + "d": { + "description": "d", + "type": "string", + }, + "e": { + "description": "e", + "type": "string", + }, + }, + "required": [ + "d", + "e", + ], + "type": "object", + } + `); + + const b = z.toJSONSchema( + z.object({ + d: field.describe("d"), + e: field.describe("e"), + }), + { + reused: "ref", + } + ); + expect(a).toMatchInlineSnapshot(` + { + "properties": { + "d": { + "description": "d", + "type": "string", + }, + "e": { + "description": "e", + "type": "string", + }, + }, + "required": [ + "d", + "e", + ], + "type": "object", + } + `); +}); + +test("top-level readonly", () => { + const A = z + .interface({ + name: z.string(), + get b() { + return B; + }, + }) + .readonly() + .meta({ id: "A" }); + + const B = z + .interface({ + name: z.string(), + get a() { + return A; + }, + }) + .readonly() + .meta({ id: "B" }); + + const result = z.toJSONSchema(A); + expect(result).toMatchInlineSnapshot(` + { + "$defs": { + "B": { + "id": "B", + "properties": { + "a": { + "$ref": "#", + }, + "name": { + "type": "string", + }, + }, + "readOnly": true, + "required": [ + "name", + "a", + ], + "type": "object", + }, + }, + "id": "A", + "properties": { + "b": { + "$ref": "#/$defs/B", + }, + "name": { + "type": "string", + }, + }, + "readOnly": true, + "required": [ + "name", + "b", + ], + "type": "object", + } + `); +}); + +test("basic registry", () => { + const myRegistry = z.registry<{ id: string }>(); + const User = z.interface({ + name: z.string(), + get posts() { + return z.array(Post); + }, + }); + + const Post = z.interface({ + title: z.string(), + content: z.string(), + get author() { + return User; + }, + }); + + myRegistry.add(User, { id: "User" }); + myRegistry.add(Post, { id: "Post" }); + + const result = z.toJSONSchema(myRegistry); + expect(result).toMatchInlineSnapshot(` + { + "schemas": { + "Post": { + "properties": { + "author": { + "$ref": "User", + }, + "content": { + "type": "string", + }, + "title": { + "type": "string", + }, + }, + "required": [ + "title", + "content", + "author", + ], + "type": "object", + }, + "User": { + "properties": { + "name": { + "type": "string", + }, + "posts": { + "items": { + "$ref": "Post", + }, + "type": "array", + }, + }, + "required": [ + "name", + "posts", + ], + "type": "object", + }, + }, + } + `); +}); diff --git a/play.ts b/play.ts index 8e4f967000..d508ee4467 100644 --- a/play.ts +++ b/play.ts @@ -1,2 +1,41 @@ import * as z from "zod"; z; + +// const a = z.string().meta({ id: "jobId", description: "a" }); +// const b = a.describe("b"); +// const c = b.describe("c"); +// const d = c.describe("d"); + +// const result = z.toJSONSchema( +// z.object({ +// a, +// b, +// b2: b, +// c, +// d, +// }) +// ); +// console.log(JSON.stringify(result, null, 2)); + +// import * as z from "zod"; + +const User = z.interface({ + name: z.string(), + get posts() { + return z.array(Post); + }, +}); + +const Post = z.interface({ + title: z.string(), + content: z.string(), + get author() { + return User; + }, +}); + +z.globalRegistry.add(User, { id: "User" }); +z.globalRegistry.add(Post, { id: "Post" }); + +const reg = z.toJSONSchema(z.globalRegistry); +console.dir(reg, { depth: 10 }); diff --git a/vitest.root.mts b/vitest.root.mts index ecb336114d..d9cf889a8f 100644 --- a/vitest.root.mts +++ b/vitest.root.mts @@ -6,7 +6,7 @@ export default defineConfig({ }, test: { watch: false, - isolate: false, + isolate: true, typecheck: { include: ["**/*.test.ts"], enabled: true,