Skip to content

Commit

Permalink
feat(zui): Serialize and deserialize type coercions (#336)
Browse files Browse the repository at this point in the history
  • Loading branch information
charlescatta authored Jun 20, 2024
1 parent 74bfe6b commit 6cf365d
Show file tree
Hide file tree
Showing 61 changed files with 1,013 additions and 510 deletions.
2 changes: 1 addition & 1 deletion zui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/zui",
"version": "0.8.8",
"version": "0.8.9",
"description": "An extension of Zod for working nicely with UIs and JSON Schemas",
"type": "module",
"source": "./src/index.ts",
Expand Down
120 changes: 1 addition & 119 deletions zui/playground.ts
Original file line number Diff line number Diff line change
@@ -1,122 +1,4 @@
import { z } from './src'
import fs from 'node:fs'

const obj1 = z
.discriminatedUnion('type', [
z.object({
type: z.literal('Credit Card'),
cardNumber: z
.string()
.title('Credit Card Number')
.placeholder('1234 5678 9012 3456')
.describe('This is the card number'),
expirationDate: z.string().title('Expiration Date').placeholder('10/29').describe('This is the expiration date'),
brand: z
.enum(['Visa', 'Mastercard', 'American Express'])
.nullable()
.optional()
.default('Visa')
.describe('This is the brand of the card'),
}),
z.object({
type: z.literal('PayPal'),
email: z
.string()
.email()
.title('Paypal Email')
.placeholder('john@doe.com')
.describe("This is the paypal account's email address"),
}),
z.object({
type: z.literal('Bitcoin'),
address: z
.string()
.title('Bitcoin Address')
.placeholder('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa')
.describe('This is the bitcoin address'),
}),
z.object({
type: z.literal('Bank Transfer'),
accountNumber: z
.string()
.title('Account Number')
.placeholder('1234567890')
.describe('This is the bank account number'),
}),
z
.object({
type: z.literal('Cash'),
amount: z
.number()
.title('Amount')
.disabled((value) => (value || 0) > 100)
.describe('This is the amount of cash'),
})
.disabled((obj) => {
return {
type: !!obj && obj.amount > 100,
}
})
.disabled(() => {
return false
}),
])
.title('payment')

const obj2 = z
.array(
z.object({
data: z.templateLiteral().literal('bro ').interpolated(z.string()).literal('!'),
name: z.string().optional().title('Name').placeholder('John Doe').describe('This is the name'),
age: z.number().nullable().title('Age').placeholder('18').describe('This is the age'),
email: z.string().email().title('Email').placeholder('The email').describe('This is the email'),
aUnion: z.union([z.string(), z.number()]).title('A Union').placeholder('A Union').describe('This is a union'),
aTuple: z.tuple([z.string(), z.number()]).title('A Tuple').placeholder('A Tuple').describe('This is a tuple'),
aRecord: z.record(z.number()).title('A Record').placeholder('A Record').describe('This is a record'),
anArray: z.array(z.number()).title('An Array').placeholder('An Array').describe('This is an array'),
aSet: z.set(z.number()).title('A Set').placeholder('A Set').describe('This is a set'),
aMap: z.map(z.string(), z.array(z.any())).title('A Map').placeholder('A Map').describe('This is a map'),
aFunction: z
.function()
.args(z.array(z.union([z.literal('bob'), z.literal('steve')], z.string())).title('names'))
.returns(z.literal('bro'))
.title('A Function')
.placeholder('A Function')
.describe('This is a function'),
aPromise: z.promise(z.number()).title('A Promise').placeholder('A Promise').describe('This is a promise'),
aLazy: z
.lazy(() => z.string())
.title('A Lazy')
.placeholder('A Lazy')
.describe('This is a lazy'),
aDate: z.date().title('A Date').placeholder('A Date').describe('This is a date'),
aOptional: z.optional(z.string()).title('An Optional').placeholder('An Optional').describe('This is an optional'),
aNullable: z.nullable(z.string()).title('A Nullable').placeholder('A Nullable').describe('This is a nullable'),
}),
)
.title('users')

const obj3 = z
.object({
address: z.lazy(() =>
z
.record(
z.number(),
z.object({
street: z.string(),
number: z.number(),
}),
)
.describe('This is a record'),
),
})
.title('MyObject')

const typings = [
obj1.toTypescript({ declaration: true }),
obj2.toTypescript({ declaration: true }),
obj3.toTypescript({ declaration: true }),
].join('\n\n')


fs.writeFileSync('./output.ts', typings)
z.object({})
32 changes: 32 additions & 0 deletions zui/src/transforms/json-schema-to-zui/json-schema-to-zui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,35 @@ describe('jsonSchemaToZui', () => {
})
})
})

describe('Coercion deserialization', () => {
it('should deserialize coerced strings correctly', () => {
const schema = z.coerce.string().toJsonSchema()
const asZui = jsonSchemaToZui(schema)
expect(asZui._def[zuiKey]?.coerce).toStrictEqual(true)
})

it('should deserialize coerced numbers correctly', () => {
const schema = z.coerce.number().toJsonSchema()
const asZui = jsonSchemaToZui(schema)
expect(asZui._def[zuiKey]?.coerce).toStrictEqual(true)
})

it('should deserialize coerced booleans correctly', () => {
const schema = z.coerce.boolean().toJsonSchema()
const asZui = jsonSchemaToZui(schema)
expect(asZui._def[zuiKey]?.coerce).toStrictEqual(true)
})

it('should deserialize coerced dates correctly', () => {
const schema = z.coerce.date().toJsonSchema()
const asZui = jsonSchemaToZui(schema)
expect(asZui._def[zuiKey]?.coerce).toStrictEqual(true)
})

it('should deserialize coerced bigints correctly', () => {
const schema = z.coerce.bigint().toJsonSchema()
const asZui = jsonSchemaToZui(schema)
expect(asZui._def[zuiKey]?.coerce).toStrictEqual(true)
})
})
4 changes: 4 additions & 0 deletions zui/src/transforms/json-schema-to-zui/parsers/parseBoolean.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { zuiKey } from '../../../ui/constants'
import { JsonSchemaObject } from '../types'

export const parseBoolean = (_schema: JsonSchemaObject & { type: 'boolean' }) => {
if (_schema[zuiKey]?.coerce) {
return 'z.coerce.boolean()'
}
return 'z.boolean()'
}
5 changes: 5 additions & 0 deletions zui/src/transforms/json-schema-to-zui/parsers/parseNumber.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { zuiKey } from '../../../ui/constants'
import { JsonSchemaObject } from '../types'
import { withMessage } from '../utils'

export const parseNumber = (schema: JsonSchemaObject & { type: 'number' | 'integer' }) => {
let r = 'z.number()'

if (schema[zuiKey]?.coerce) {
r = 'z.coerce.number()'
}

if (schema.type === 'integer') {
r += withMessage(schema, 'type', () => ['.int(', ')'])
} else {
Expand Down
7 changes: 7 additions & 0 deletions zui/src/transforms/json-schema-to-zui/parsers/parseString.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { zuiKey } from '../../../ui/constants'
import { JsonSchemaObject } from '../types'
import { withMessage } from '../utils'

export const parseString = (schema: JsonSchemaObject & { type: 'string' }) => {
let r = 'z.string()'
if (schema[zuiKey]?.coerce) {
if (schema.format === 'date-time') {
return 'z.coerce.date()'
}
r = 'z.coerce.string()'
}

r += withMessage(schema, 'format', ({ value }) => {
switch (value) {
Expand Down
4 changes: 4 additions & 0 deletions zui/src/transforms/json-schema-to-zui/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { zuiKey } from '../../ui/constants'
import { ZuiExtensionObject } from '../../ui/types'

export type Serializable = { [key: string]: Serializable } | Serializable[] | string | number | boolean | null

export type JsonSchema = JsonSchemaObject | boolean
Expand Down Expand Up @@ -49,6 +52,7 @@ export type JsonSchemaObject = {
enum?: Serializable[]

errorMessage?: { [key: string]: string | undefined }
[zuiKey]?: ZuiExtensionObject
} & { [key: string]: any }

export type ParserSelector = (schema: JSONSchemaExtended, refs: Refs) => string
Expand Down
4 changes: 3 additions & 1 deletion zui/src/transforms/object-to-zui/object-to-zui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ describe('object-to-zui', () => {

for (const key in schema.properties) {
expect(schema.properties[key]).toHaveProperty('type')
expect(schema.properties[key]).toHaveProperty('x-zui')
}
})

Expand All @@ -45,12 +44,15 @@ describe('object-to-zui', () => {
"properties": {
"city": {
"type": "string",
"x-zui": {},
},
"state": {
"type": "string",
"x-zui": {},
},
"street": {
"type": "string",
"x-zui": {},
},
},
"type": "object",
Expand Down
10 changes: 5 additions & 5 deletions zui/src/transforms/zui-to-json-schema/parseDef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function parseDef(

refs.seen.set(def, newItem)

const jsonSchema = selectParser(def, (def as any).typeName, refs)
const jsonSchema = selectParser(def, def.typeName, refs)

if (jsonSchema) {
addMeta(def, refs, jsonSchema)
Expand Down Expand Up @@ -146,7 +146,7 @@ const selectParser = (def: any, typeName: ZodFirstPartyTypeKind, refs: Refs): Js
case ZodFirstPartyTypeKind.ZodBigInt:
return parseBigintDef(def, refs)
case ZodFirstPartyTypeKind.ZodBoolean:
return parseBooleanDef()
return parseBooleanDef(def)
case ZodFirstPartyTypeKind.ZodDate:
return parseDateDef(def, refs)
case ZodFirstPartyTypeKind.ZodUndefined:
Expand Down Expand Up @@ -222,8 +222,8 @@ const addMeta = (def: ZodTypeDef, refs: Refs, jsonSchema: JsonSchema7Type): Json
jsonSchema.markdownDescription = def.description
}
}
if ((def as any)[zuiKey]) {
;(jsonSchema as any)[zuiKey] = (def as any)[zuiKey]
}

Object.assign(jsonSchema, { [zuiKey]: { ...def[zuiKey], ...(jsonSchema as any)[zuiKey] } || {} })

return jsonSchema
}
7 changes: 6 additions & 1 deletion zui/src/transforms/zui-to-json-schema/parsers/any.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export type JsonSchema7AnyType = {}
import { zuiKey } from '../../../ui/constants'
import { ZuiExtensionObject } from '../../../ui/types'

export type JsonSchema7AnyType = {
[zuiKey]?: ZuiExtensionObject
}

export function parseAnyDef(): JsonSchema7AnyType {
return {}
Expand Down
4 changes: 4 additions & 0 deletions zui/src/transforms/zui-to-json-schema/parsers/array.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { zuiKey } from '../../../ui/constants'
import { ZuiExtensionObject } from '../../../ui/types'
import { ZodArrayDef, ZodFirstPartyTypeKind } from '../../../z/index'
import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages'
import { JsonSchema7Type, parseDef } from '../parseDef'
Expand All @@ -9,12 +11,14 @@ export type JsonSchema7ArrayType = {
minItems?: number
maxItems?: number
errorMessages?: ErrorMessages<JsonSchema7ArrayType, 'items'>
[zuiKey]?: ZuiExtensionObject
}

export function parseArrayDef(def: ZodArrayDef, refs: Refs) {
const res: JsonSchema7ArrayType = {
type: 'array',
}

if (def.type?._def?.typeName !== ZodFirstPartyTypeKind.ZodAny) {
res.items = parseDef(def.type._def, {
...refs,
Expand Down
10 changes: 10 additions & 0 deletions zui/src/transforms/zui-to-json-schema/parsers/bigint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { zuiKey } from '../../../ui/constants'
import { ZuiExtensionObject } from '../../../ui/types'
import { ZodBigIntDef } from '../../../z/index'
import { Refs } from '../Refs'
import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages'
Expand All @@ -11,12 +13,20 @@ export type JsonSchema7BigintType = {
exclusiveMaximum?: BigInt
multipleOf?: BigInt
errorMessage?: ErrorMessages<JsonSchema7BigintType>
[zuiKey]?: ZuiExtensionObject
}

export function parseBigintDef(def: ZodBigIntDef, refs: Refs): JsonSchema7BigintType {
const res: JsonSchema7BigintType = {
type: 'integer',
format: 'int64',
...(def.coerce
? {
[zuiKey]: {
coerce: def.coerce || undefined,
},
}
: {}),
}

if (!def.checks) return res
Expand Down
14 changes: 13 additions & 1 deletion zui/src/transforms/zui-to-json-schema/parsers/boolean.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { zuiKey } from '../../../ui/constants'
import { ZuiExtensionObject } from '../../../ui/types'
import { ZodBooleanDef } from '../../../z'

export type JsonSchema7BooleanType = {
type: 'boolean'
[zuiKey]?: ZuiExtensionObject
}

export function parseBooleanDef(): JsonSchema7BooleanType {
export function parseBooleanDef(def: ZodBooleanDef): JsonSchema7BooleanType {
return {
type: 'boolean',
...(def.coerce
? {
[zuiKey]: {
coerce: def.coerce || undefined,
},
}
: {}),
}
}
Loading

0 comments on commit 6cf365d

Please sign in to comment.