UNPKG

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
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 }