remix-params-helper
Version:
This package makes it simple to use Zod with standard URLSearchParams and FormData which are typically used in Remix apps.
276 lines (259 loc) • 7.84 kB
text/typescript
import {
z,
ZodArray,
ZodBoolean,
ZodDate,
ZodDefault,
ZodEffects,
ZodEnum,
ZodLiteral,
ZodNativeEnum,
ZodNumber,
ZodObject,
ZodOptional,
ZodString,
ZodType,
ZodTypeAny,
} from 'zod'
function isIterable(
maybeIterable: unknown,
): maybeIterable is Iterable<unknown> {
return Symbol.iterator in Object(maybeIterable)
}
function parseParams(o: any, schema: any, key: string, value: any) {
// find actual shape definition for this key
let shape = schema
while (shape instanceof ZodObject || shape instanceof ZodEffects) {
shape =
shape instanceof ZodObject
? shape.shape
: shape instanceof ZodEffects
? shape._def.schema
: null
if (shape === null) {
throw new Error(`Could not find shape for key ${key}`)
}
}
if (key.includes('.')) {
let [parentProp, ...rest] = key.split('.')
o[parentProp] = o[parentProp] ?? {}
parseParams(o[parentProp], shape[parentProp], rest.join('.'), value)
return
}
let isArray = false
if (key.includes('[]')) {
isArray = true
key = key.replace('[]', '')
}
const def = shape[key]
if (def) {
processDef(def, o, key, value as string)
}
}
function getParamsInternal<T>(
params: URLSearchParams | FormData | Record<string, string | undefined>,
schema: any,
):
| { success: true; data: T; errors: undefined }
| { success: false; data: undefined; errors: { [key: string]: string } } {
// @ts-ignore
let o: any = {}
let entries: [string, unknown][] = []
if (isIterable(params)) {
entries = Array.from(params)
} else {
entries = Object.entries(params)
}
for (let [key, value] of entries) {
// infer an empty param as if it wasn't defined in the first place
if (value === '') {
continue
}
parseParams(o, schema, key, value)
}
const result = schema.safeParse(o)
if (result.success) {
return { success: true, data: result.data as T, errors: undefined }
} else {
let errors: any = {}
const addError = (key: string, message: string) => {
if (!errors.hasOwnProperty(key)) {
errors[key] = message
} else {
if (!Array.isArray(errors[key])) {
errors[key] = [errors[key]]
}
errors[key].push(message)
}
}
for (let issue of result.error.issues) {
const { message, path, code, expected, received } = issue
const [key, index] = path
let value = o[key]
let prop = key
if (index !== undefined) {
value = value[index]
prop = `${key}[${index}]`
}
addError(key, message)
}
return { success: false, data: undefined, errors }
}
}
export function getParams<T extends ZodType<any, any, any>>(
params: URLSearchParams | FormData | Record<string, string | undefined>,
schema: T,
) {
type ParamsType = z.infer<T>
return getParamsInternal<ParamsType>(params, schema)
}
export function getSearchParams<T extends ZodType<any, any, any>>(
request: Pick<Request, 'url'>,
schema: T,
) {
type ParamsType = z.infer<T>
let url = new URL(request.url)
return getParamsInternal<ParamsType>(url.searchParams, schema)
}
export async function getFormData<T extends ZodType<any, any, any>>(
request: Pick<Request, 'formData'>,
schema: T,
) {
type ParamsType = z.infer<T>
let data = await request.formData()
return getParamsInternal<ParamsType>(data, schema)
}
export function getParamsOrFail<T extends ZodType<any, any, any>>(
params: URLSearchParams | FormData | Record<string, string | undefined>,
schema: T,
) {
type ParamsType = z.infer<T>
const result = getParamsInternal<ParamsType>(params, schema)
if (!result.success) {
throw new Error(JSON.stringify(result.errors))
}
return result.data
}
export function getSearchParamsOrFail<T extends ZodType<any, any, any>>(
request: Pick<Request, 'url'>,
schema: T,
) {
type ParamsType = z.infer<T>
let url = new URL(request.url)
const result = getParamsInternal<ParamsType>(url.searchParams, schema)
if (!result.success) {
throw new Error(JSON.stringify(result.errors))
}
return result.data
}
export async function getFormDataOrFail<T extends ZodType<any, any, any>>(
request: Pick<Request, 'formData'>,
schema: T,
) {
type ParamsType = z.infer<T>
let data = await request.formData()
const result = getParamsInternal<ParamsType>(data, schema)
if (!result.success) {
throw new Error(JSON.stringify(result.errors))
}
return result.data
}
export type InputPropType = {
name: string
type: string
required?: boolean
min?: number
max?: number
minLength?: number
maxLength?: number
pattern?: string
}
export function useFormInputProps(schema: any, options: any = {}) {
const shape = schema.shape
const defaultOptions = options
return function props(key: string, options: any = {}) {
options = { ...defaultOptions, ...options }
const def = shape[key]
if (!def) {
throw new Error(`no such key: ${key}`)
}
return getInputProps(key, def)
}
}
function processDef(def: ZodTypeAny, o: any, key: string, value: string) {
let parsedValue: any
if (def instanceof ZodString || def instanceof ZodLiteral) {
parsedValue = value
} else if (def instanceof ZodNumber) {
const num = Number(value)
parsedValue = isNaN(num) ? value : num
} else if (def instanceof ZodDate) {
const date = Date.parse(value)
parsedValue = isNaN(date) ? value : new Date(date)
} else if (def instanceof ZodBoolean) {
parsedValue =
value === 'true' ? true : value === 'false' ? false : Boolean(value)
} else if (def instanceof ZodNativeEnum || def instanceof ZodEnum) {
parsedValue = value
} else if (def instanceof ZodOptional || def instanceof ZodDefault) {
// def._def.innerType is the same as ZodOptional's .unwrap(), which unfortunately doesn't exist on ZodDefault
processDef(def._def.innerType, o, key, value)
// return here to prevent overwriting the result of the recursive call
return
} else if (def instanceof ZodArray) {
if (o[key] === undefined) {
o[key] = []
}
processDef(def.element, o, key, value)
// return here since recursive call will add to array
return
} else if (def instanceof ZodEffects) {
processDef(def._def.schema, o, key, value)
return
} else {
throw new Error(`Unexpected type ${def._def.typeName} for key ${key}`)
}
if (Array.isArray(o[key])) {
o[key].push(parsedValue)
} else {
o[key] = parsedValue
}
}
function getInputProps(name: string, def: ZodTypeAny): InputPropType {
let type = 'text'
let min, max, minlength, maxlength, pattern
if (def instanceof ZodString) {
if (def.isEmail) {
type = 'email'
} else if (def.isURL) {
type = 'url'
}
minlength = def.minLength ?? undefined
maxlength = def.maxLength ?? undefined
const check: any = def._def.checks.find(c => c.kind === 'regex')
pattern = check ? check.regex.source : undefined
} else if (def instanceof ZodNumber) {
type = 'number'
min = def.minValue ?? undefined
max = def.maxValue ?? undefined
} else if (def instanceof ZodBoolean) {
type = 'checkbox'
} else if (def instanceof ZodDate) {
type = 'date'
} else if (def instanceof ZodArray) {
return getInputProps(name, def.element)
} else if (def instanceof ZodOptional) {
return getInputProps(name, def.unwrap())
}
let inputProps: InputPropType = {
name,
type,
}
if (!def.isOptional()) inputProps.required = true
if (min) inputProps.min = min
if (max) inputProps.max = max
if (minlength && Number.isFinite(minlength)) inputProps.minLength = minlength
if (maxlength && Number.isFinite(maxlength)) inputProps.maxLength = maxlength
if (pattern) inputProps.pattern = pattern
return inputProps
}