@kubb/plugin-zod
Version:
Zod schema generator plugin for Kubb, creating type-safe validation schemas from OpenAPI specifications for runtime data validation.
463 lines (398 loc) • 15.6 kB
text/typescript
import transformers from '@kubb/core/transformers'
import type { SchemaObject } from '@kubb/oas'
import type { Schema, SchemaKeywordBase, SchemaMapper } from '@kubb/plugin-oas'
import { isKeyword, SchemaGenerator, type SchemaKeywordMapper, type SchemaTree, schemaKeywords } from '@kubb/plugin-oas'
const zodKeywordMapper = {
any: () => 'z.any()',
unknown: () => 'z.unknown()',
void: () => 'z.void()',
number: (coercion?: boolean, min?: number, max?: number) => {
return [coercion ? 'z.coerce.number()' : 'z.number()', min !== undefined ? `.min(${min})` : undefined, max !== undefined ? `.max(${max})` : undefined]
.filter(Boolean)
.join('')
},
integer: (coercion?: boolean, min?: number, max?: number, version: '3' | '4' = '3') => {
return [
coercion ? 'z.coerce.number().int()' : version === '4' ? 'z.int()' : 'z.number().int()',
min !== undefined ? `.min(${min})` : undefined,
max !== undefined ? `.max(${max})` : undefined,
]
.filter(Boolean)
.join('')
},
interface: (value?: string, strict?: boolean) => {
if (strict) {
return `z.strictInterface({
${value}
})`
}
return `z.interface({
${value}
})`
},
object: (value?: string, strict?: boolean, version: '3' | '4' = '3') => {
if (version === '4' && strict) {
return `z.strictObject({
${value}
})`
}
if (strict) {
return `z.object({
${value}
}).strict()`
}
return `z.object({
${value}
})`
},
string: (coercion?: boolean, min?: number, max?: number) => {
return [coercion ? 'z.coerce.string()' : 'z.string()', min !== undefined ? `.min(${min})` : undefined, max !== undefined ? `.max(${max})` : undefined]
.filter(Boolean)
.join('')
},
//support for discriminatedUnion
boolean: () => 'z.boolean()',
undefined: () => 'z.undefined()',
nullable: () => '.nullable()',
null: () => 'z.null()',
nullish: () => '.nullish()',
array: (items: string[] = [], min?: number, max?: number, unique?: boolean) => {
return [
`z.array(${items?.join('')})`,
min !== undefined ? `.min(${min})` : undefined,
max !== undefined ? `.max(${max})` : undefined,
unique ? `.refine(items => new Set(items).size === items.length, { message: "Array entries must be unique" })` : undefined,
]
.filter(Boolean)
.join('')
},
tuple: (items: string[] = []) => `z.tuple([${items?.join(', ')}])`,
enum: (items: string[] = []) => `z.enum([${items?.join(', ')}])`,
union: (items: string[] = []) => `z.union([${items?.join(', ')}])`,
const: (value?: string | number | boolean) => `z.literal(${value ?? ''})`,
/**
* ISO 8601
*/
datetime: (offset = false, local = false, version: '3' | '4' = '3') => {
if (offset) {
return version === '4' ? `z.iso.datetime({ offset: ${offset} })` : `z.string().datetime({ offset: ${offset} })`
}
if (local) {
return version === '4' ? `z.iso.datetime({ local: ${local} })` : `z.string().datetime({ local: ${local} })`
}
return 'z.string().datetime()'
},
/**
* Type `'date'` Date
* Type `'string'` ISO date format (YYYY-MM-DD)
* @default ISO date format (YYYY-MM-DD)
*/
date: (type: 'date' | 'string' = 'string', coercion?: boolean, version: '3' | '4' = '3') => {
if (type === 'string') {
return version === '4' ? 'z.iso.date()' : 'z.string().date()'
}
if (coercion) {
return 'z.coerce.date()'
}
return 'z.date()'
},
/**
* Type `'date'` Date
* Type `'string'` ISO time format (HH:mm:ss[.SSSSSS])
* @default ISO time format (HH:mm:ss[.SSSSSS])
*/
time: (type: 'date' | 'string' = 'string', coercion?: boolean, version: '3' | '4' = '3') => {
if (type === 'string') {
return version === '4' ? 'z.iso.time()' : 'z.string().time()'
}
if (coercion) {
return 'z.coerce.date()'
}
return 'z.date()'
},
uuid: (coercion?: boolean, version: '3' | '4' = '3') =>
version === '4' ? (coercion ? 'z.coerce.string().uuid()' : 'z.uuid()') : coercion ? 'z.coerce.string().uuid()' : 'z.string().uuid()',
url: (coercion?: boolean, version: '3' | '4' = '3') =>
version === '4' ? (coercion ? 'z.coerce.string().url()' : 'z.url()') : coercion ? 'z.coerce.string().url()' : 'z.string().url()',
default: (value?: string | number | true | object) => {
if (typeof value === 'object') {
return '.default({})'
}
return `.default(${value ?? ''})`
},
and: (items: string[] = []) => items?.map((item) => `.and(${item})`).join(''),
describe: (value = '') => `.describe(${value})`,
min: (value?: number) => `.min(${value ?? ''})`,
max: (value?: number) => `.max(${value ?? ''})`,
optional: () => '.optional()',
matches: (value = '', coercion?: boolean) => (coercion ? `z.coerce.string().regex(${value})` : `z.string().regex(${value})`),
email: (coercion?: boolean, version: '3' | '4' = '3') =>
version === '4' ? (coercion ? 'z.coerce.string().email()' : 'z.email()') : coercion ? 'z.coerce.string().email()' : 'z.string().email()',
firstName: undefined,
lastName: undefined,
password: undefined,
phone: undefined,
readOnly: undefined,
writeOnly: undefined,
ref: (value?: string, version: '3' | '4' = '3') => {
if (!value) {
return undefined
}
return version === '4' ? value : `z.lazy(() => ${value})`
},
blob: () => 'z.instanceof(File)',
deprecated: undefined,
example: undefined,
schema: undefined,
catchall: (value?: string) => (value ? `.catchall(${value})` : undefined),
name: undefined,
} satisfies SchemaMapper<string | null | undefined>
/**
* @link based on https://github.com/cellular/oazapfts/blob/7ba226ebb15374e8483cc53e7532f1663179a22c/src/codegen/generate.ts#L398
*/
export function sort(items?: Schema[]): Schema[] {
const order: string[] = [
schemaKeywords.string,
schemaKeywords.datetime,
schemaKeywords.date,
schemaKeywords.time,
schemaKeywords.tuple,
schemaKeywords.number,
schemaKeywords.object,
schemaKeywords.enum,
schemaKeywords.url,
schemaKeywords.email,
schemaKeywords.firstName,
schemaKeywords.lastName,
schemaKeywords.password,
schemaKeywords.matches,
schemaKeywords.uuid,
schemaKeywords.null,
schemaKeywords.min,
schemaKeywords.max,
schemaKeywords.default,
schemaKeywords.describe,
schemaKeywords.optional,
schemaKeywords.nullable,
schemaKeywords.nullish,
]
if (!items) {
return []
}
return transformers.orderBy(items, [(v) => order.indexOf(v.keyword)], ['asc'])
}
const shouldCoerce = (coercion: ParserOptions['coercion'] | undefined, type: 'dates' | 'strings' | 'numbers'): boolean => {
if (coercion === undefined) {
return false
}
if (typeof coercion === 'boolean') {
return coercion
}
return !!coercion[type]
}
type ParserOptions = {
name: string
typeName?: string
description?: string
keysToOmit?: string[]
mapper?: Record<string, string>
coercion?: boolean | { dates?: boolean; strings?: boolean; numbers?: boolean }
wrapOutput?: (opts: { output: string; schema: any }) => string | undefined
rawSchema: SchemaObject
version: '3' | '4'
}
export function parse({ parent, current, name, siblings }: SchemaTree, options: ParserOptions): string | undefined {
const value = zodKeywordMapper[current.keyword as keyof typeof zodKeywordMapper]
// Early exit: if siblings contain both matches and ref → skip matches entirely
const hasMatches = siblings.some((it) => isKeyword(it, schemaKeywords.matches))
const hasRef = siblings.some((it) => isKeyword(it, schemaKeywords.ref))
if (hasMatches && hasRef && isKeyword(current, schemaKeywords.matches)) {
return undefined // strip matches
}
if (!value) {
return undefined
}
if (isKeyword(current, schemaKeywords.union)) {
// zod union type needs at least 2 items
if (Array.isArray(current.args) && current.args.length === 1) {
return parse({ parent, name: name, current: current.args[0] as Schema, siblings }, options)
}
if (Array.isArray(current.args) && !current.args.length) {
return ''
}
return zodKeywordMapper.union(
sort(current.args)
.map((schema, _index, siblings) => parse({ parent: current, name: name, current: schema, siblings }, options))
.filter(Boolean),
)
}
if (isKeyword(current, schemaKeywords.and)) {
const items = sort(current.args)
.filter((schema: Schema) => {
return ![schemaKeywords.optional, schemaKeywords.describe].includes(schema.keyword as typeof schemaKeywords.describe)
})
.map((schema: Schema, _index, siblings) => parse({ parent: current, name: name, current: schema, siblings }, options))
.filter(Boolean)
return `${items.slice(0, 1)}${zodKeywordMapper.and(items.slice(1))}`
}
if (isKeyword(current, schemaKeywords.array)) {
return zodKeywordMapper.array(
sort(current.args.items)
.map((schemas, _index, siblings) => parse({ parent: current, name: name, current: schemas, siblings }, options))
.filter(Boolean),
current.args.min,
current.args.max,
current.args.unique,
)
}
if (isKeyword(current, schemaKeywords.enum)) {
if (current.args.asConst) {
if (current.args.items.length === 1) {
const child = {
keyword: schemaKeywords.const,
args: current.args.items[0],
}
return parse({ parent: current, name: name, current: child, siblings: [child] }, options)
}
return zodKeywordMapper.union(
current.args.items
.map((schema) => ({
keyword: schemaKeywords.const,
args: schema,
}))
.map((schema, _index, siblings) => {
return parse({ parent: current, name: name, current: schema, siblings }, options)
})
.filter(Boolean),
)
}
return zodKeywordMapper.enum(
current.args.items.map((schema) => {
if (schema.format === 'boolean') {
return transformers.stringify(schema.value)
}
if (schema.format === 'number') {
return transformers.stringify(schema.value)
}
return transformers.stringify(schema.value)
}),
)
}
if (isKeyword(current, schemaKeywords.ref)) {
return zodKeywordMapper.ref(current.args?.name, options.version)
}
if (isKeyword(current, schemaKeywords.object)) {
const propertyEntries = Object.entries(current.args?.properties || {}).filter((item) => {
const schema = item[1]
return schema && typeof schema.map === 'function'
})
const properties = propertyEntries
.map(([name, schemas]) => {
const nameSchema = schemas.find((schema) => schema.keyword === schemaKeywords.name) as SchemaKeywordMapper['name']
const mappedName = nameSchema?.args || name
// custom mapper(pluginOptions)
if (options.mapper?.[mappedName]) {
return `"${name}": ${options.mapper?.[mappedName]}`
}
const baseSchemaOutput = sort(schemas)
.map((schema) => parse({ parent: current, name, current: schema, siblings: schemas }, options))
.filter(Boolean)
.join('')
const objectValue = options.wrapOutput
? options.wrapOutput({ output: baseSchemaOutput, schema: options.rawSchema?.properties?.[name] }) || baseSchemaOutput
: baseSchemaOutput
if (options.version === '4' && SchemaGenerator.find(schemas, schemaKeywords.ref)) {
return `get ${name}(){
return ${objectValue}
}`
}
return `"${name}": ${objectValue}`
})
.join(',\n')
const additionalProperties = current.args?.additionalProperties?.length
? current.args.additionalProperties
.map((schema, _index, siblings) => parse({ parent: current, name: name, current: schema, siblings }, options))
.filter(Boolean)
.join('')
: undefined
const text = [
zodKeywordMapper.object(properties, current.args?.strict, options.version),
additionalProperties ? zodKeywordMapper.catchall(additionalProperties) : undefined,
].filter(Boolean)
return text.join('')
}
if (isKeyword(current, schemaKeywords.tuple)) {
return zodKeywordMapper.tuple(
current.args.items.map((schema, _index, siblings) => parse({ parent: current, name: name, current: schema, siblings }, options)).filter(Boolean),
)
}
if (isKeyword(current, schemaKeywords.const)) {
if (current.args.format === 'number' && current.args.value !== undefined) {
return zodKeywordMapper.const(Number(current.args.value))
}
if (current.args.format === 'boolean' && current.args.value !== undefined) {
return zodKeywordMapper.const(current.args.value)
}
return zodKeywordMapper.const(transformers.stringify(current.args.value))
}
if (isKeyword(current, schemaKeywords.matches)) {
if (current.args) {
return zodKeywordMapper.matches(transformers.toRegExpString(current.args, null), shouldCoerce(options.coercion, 'strings'))
}
}
if (isKeyword(current, schemaKeywords.default)) {
if (current.args) {
return zodKeywordMapper.default(current.args)
}
}
if (isKeyword(current, schemaKeywords.describe)) {
if (current.args) {
return zodKeywordMapper.describe(transformers.stringify(current.args.toString()))
}
}
if (isKeyword(current, schemaKeywords.string)) {
return zodKeywordMapper.string(shouldCoerce(options.coercion, 'strings'))
}
if (isKeyword(current, schemaKeywords.uuid)) {
return zodKeywordMapper.uuid(shouldCoerce(options.coercion, 'strings'), options.version)
}
if (isKeyword(current, schemaKeywords.email)) {
return zodKeywordMapper.email(shouldCoerce(options.coercion, 'strings'), options.version)
}
if (isKeyword(current, schemaKeywords.url)) {
return zodKeywordMapper.url(shouldCoerce(options.coercion, 'strings'), options.version)
}
if (isKeyword(current, schemaKeywords.number)) {
return zodKeywordMapper.number(shouldCoerce(options.coercion, 'numbers'))
}
if (isKeyword(current, schemaKeywords.integer)) {
return zodKeywordMapper.integer(shouldCoerce(options.coercion, 'numbers'), undefined, undefined, options.version)
}
if (isKeyword(current, schemaKeywords.min)) {
return zodKeywordMapper.min(current.args)
}
if (isKeyword(current, schemaKeywords.max)) {
return zodKeywordMapper.max(current.args)
}
if (isKeyword(current, schemaKeywords.datetime)) {
return zodKeywordMapper.datetime(current.args.offset, current.args.local, options.version)
}
if (isKeyword(current, schemaKeywords.date)) {
return zodKeywordMapper.date(current.args.type, shouldCoerce(options.coercion, 'dates'), options.version)
}
if (isKeyword(current, schemaKeywords.time)) {
return zodKeywordMapper.time(current.args.type, shouldCoerce(options.coercion, 'dates'), options.version)
}
if (current.keyword in zodKeywordMapper && 'args' in current) {
const value = zodKeywordMapper[current.keyword as keyof typeof zodKeywordMapper] as (typeof zodKeywordMapper)['const']
return value((current as SchemaKeywordBase<unknown>).args as any)
}
if (isKeyword(current, schemaKeywords.optional)) {
if (siblings.some((schema) => isKeyword(schema, schemaKeywords.default))) return ''
return value()
}
if (current.keyword in zodKeywordMapper) {
return value()
}
return undefined
}