From a4ca5a9291020d072725b37361d8ddf4fbea26fd Mon Sep 17 00:00:00 2001 From: Maxime Doury Date: Mon, 12 Dec 2022 00:35:58 +0100 Subject: [PATCH] feat: Support asynchronous schema for and (#20) --- src/parsers.test.ts | 249 ++++++++++++++++++++++++++++++++++---------- src/parsers.ts | 9 +- 2 files changed, 198 insertions(+), 60 deletions(-) diff --git a/src/parsers.test.ts b/src/parsers.test.ts index ddbe8dd..81a01d0 100644 --- a/src/parsers.test.ts +++ b/src/parsers.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { LoaderArgs } from '@remix-run/server-runtime'; +import type { ZodEffects, ZodObject, ZodRawShape } from 'zod'; import { FormData, NodeOnDiskFile, Request } from '@remix-run/node'; import { z } from 'zod'; import { zx } from './'; @@ -10,26 +11,29 @@ describe('parseParams', () => { type Result = { id: string; age: number }; const params: Params = { id: 'id1', age: '10' }; const paramsResult = { id: 'id1', age: 10 }; - const schema = z.object({ id: z.string(), age: zx.IntAsString }); + const objectSchema = { id: z.string(), age: zx.IntAsString }; + const zodSchema = z.object(objectSchema); test('parses params using an object', () => { - const result = zx.parseParams(params, { - id: z.string(), - age: zx.IntAsString, - }); + const result = zx.parseParams(params, objectSchema); expect(result).toStrictEqual(paramsResult); type verify = Expect>; }); test('parses params using a schema', () => { - const result = zx.parseParams(params, schema); + const result = zx.parseParams(params, zodSchema); expect(result).toStrictEqual(paramsResult); type verify = Expect>; }); - test('throws for invalid params', () => { + test('throws for invalid params using an object', () => { const badParams = { ...params, age: 'not a number' }; - expect(() => zx.parseParams(badParams, schema)).toThrow(); + expect(() => zx.parseParams(badParams, objectSchema)).toThrow(); + }); + + test('throws for invalid params using a schema', () => { + const badParams = { ...params, age: 'not a number' }; + expect(() => zx.parseParams(badParams, zodSchema)).toThrow(); }); }); @@ -37,7 +41,8 @@ describe('parseParamsSafe', () => { type Result = { id: string; age: number }; const params: Params = { id: 'id1', age: '10' }; const paramsResult = { id: 'id1', age: 10 }; - const schema = z.object({ id: z.string(), age: zx.IntAsString }); + const objectSchema = { id: z.string(), age: zx.IntAsString }; + const zodSchema = z.object(objectSchema); test('parses params using an object', () => { const result = zx.parseParamsSafe(params, { @@ -51,16 +56,25 @@ describe('parseParamsSafe', () => { }); test('parses params using a schema', () => { - const result = zx.parseParamsSafe(params, schema); + const result = zx.parseParamsSafe(params, zodSchema); expect(result.success).toBe(true); if (result.success !== true) throw new Error('Parsing failed'); expect(result.data).toStrictEqual(paramsResult); type verify = Expect>; }); - test('returns an error for invalid params', () => { + test('returns an error for invalid params using an object', () => { const badParams = { ...params, age: 'not a number' }; - const result = zx.parseParamsSafe(badParams, schema); + const result = zx.parseParamsSafe(badParams, objectSchema); + expect(result.success).toBe(false); + if (result.success !== false) throw new Error('Parsing should have failed'); + expect(result.error.issues.length).toBe(1); + expect(result.error.issues[0].path[0]).toBe('age'); + }); + + test('returns an error for invalid params using a schema', () => { + const badParams = { ...params, age: 'not a number' }; + const result = zx.parseParamsSafe(badParams, zodSchema); expect(result.success).toBe(false); if (result.success !== false) throw new Error('Parsing should have failed'); expect(result.error.issues.length).toBe(1); @@ -70,15 +84,16 @@ describe('parseParamsSafe', () => { describe('parseQuery', () => { type Result = { id: string; age: number; friends?: string[] }; + const search = new URLSearchParams({ id: 'id1', age: '10' }); const queryResult = { id: 'id1', age: 10 }; - const schema = z.object({ + const objectSchema = { id: z.string(), age: zx.IntAsString, friends: z.array(z.string()).optional(), - }); + }; + const zodSchema = z.object(objectSchema); test('parses URLSearchParams using an object', () => { - const search = new URLSearchParams({ id: 'id1', age: '10' }); const result = zx.parseQuery(search, { id: z.string(), age: zx.IntAsString, @@ -89,13 +104,12 @@ describe('parseQuery', () => { }); test('parses URLSearchParams using a schema', () => { - const search = new URLSearchParams({ id: 'id1', age: '10' }); - const result = zx.parseQuery(search, schema); + const result = zx.parseQuery(search, zodSchema); expect(result).toStrictEqual(queryResult); type verify = Expect>; }); - test('parses arrays from URLSearchParams', () => { + test('parses arrays from URLSearchParams using an object', () => { const search = new URLSearchParams({ id: 'id1', age: '10' }); search.append('friends', 'friend1'); search.append('friends', 'friend2'); @@ -111,8 +125,19 @@ describe('parseQuery', () => { type verify = Expect>; }); - test('parses query string from a Request using an object', () => { + test('parses arrays from URLSearchParams using a schema', () => { const search = new URLSearchParams({ id: 'id1', age: '10' }); + search.append('friends', 'friend1'); + search.append('friends', 'friend2'); + const result = zx.parseQuery(search, zodSchema); + expect(result).toStrictEqual({ + ...queryResult, + friends: ['friend1', 'friend2'], + }); + type verify = Expect>; + }); + + test('parses query string from a Request using an object', () => { const request = new Request(`http://example.com?${search.toString()}`); const result = zx.parseQuery(request, { id: z.string(), @@ -124,19 +149,23 @@ describe('parseQuery', () => { }); test('parses query string from a Request using a schema', () => { - const search = new URLSearchParams({ id: 'id1', age: '10' }); const request = new Request(`http://example.com?${search.toString()}`); - const result = zx.parseQuery(request, schema); + const result = zx.parseQuery(request, zodSchema); expect(result).toStrictEqual(queryResult); type verify = Expect>; }); - test('throws for invalid query params', () => { + test('throws for invalid query params using an object', () => { const badRequest = new Request(`http://example.com?id=id1&age=notanumber`); - expect(() => zx.parseQuery(badRequest, schema)).toThrow(); + expect(() => zx.parseQuery(badRequest, zodSchema)).toThrow(); }); - test('supports custom URLSearchParam parsers', () => { + test('throws for invalid query params using a schema', () => { + const badRequest = new Request(`http://example.com?id=id1&age=notanumber`); + expect(() => zx.parseQuery(badRequest, zodSchema)).toThrow(); + }); + + test('supports custom URLSearchParam parsers using an object', () => { const search = new URLSearchParams( `?id=id1&age=10&friends[]=friend1&friends[]=friend2` ); @@ -155,12 +184,26 @@ describe('parseQuery', () => { }); type verify = Expect>; }); + + test('supports custom URLSearchParam parsers using a schema', () => { + const search = new URLSearchParams( + `?id=id1&age=10&friends[]=friend1&friends[]=friend2` + ); + const result = zx.parseQuery(search, zodSchema, { + parser: customArrayParser, + }); + expect(result).toStrictEqual({ + ...queryResult, + friends: ['friend1', 'friend2'], + }); + type verify = Expect>; + }); }); describe('parseQuerySafe', () => { type Result = { id: string; age: number; friends?: string[] }; const queryResult = { id: 'id1', age: 10 }; - const schema = z.object({ + const zodSchema = z.object({ id: z.string(), age: zx.IntAsString, friends: z.array(z.string()).optional(), @@ -181,14 +224,14 @@ describe('parseQuerySafe', () => { test('parses URLSearchParams using a schema', () => { const search = new URLSearchParams({ id: 'id1', age: '10' }); - const result = zx.parseQuerySafe(search, schema); + const result = zx.parseQuerySafe(search, zodSchema); expect(result.success).toBe(true); if (result.success !== true) throw new Error('Parsing failed'); expect(result.data).toStrictEqual(queryResult); type verify = Expect>; }); - test('parses arrays from URLSearchParams', () => { + test('parses arrays from URLSearchParams using an object', () => { const search = new URLSearchParams({ id: 'id1', age: '10' }); search.append('friends', 'friend1'); search.append('friends', 'friend2'); @@ -206,6 +249,20 @@ describe('parseQuerySafe', () => { type verify = Expect>; }); + test('parses arrays from URLSearchParams using a schema', () => { + const search = new URLSearchParams({ id: 'id1', age: '10' }); + search.append('friends', 'friend1'); + search.append('friends', 'friend2'); + const result = zx.parseQuerySafe(search, zodSchema); + expect(result.success).toBe(true); + if (result.success !== true) throw new Error('Parsing failed'); + expect(result.data).toStrictEqual({ + ...queryResult, + friends: ['friend1', 'friend2'], + }); + type verify = Expect>; + }); + test('parses query string from a Request using an object', () => { const search = new URLSearchParams({ id: 'id1', age: '10' }); const request = new Request(`http://example.com?${search.toString()}`); @@ -223,16 +280,16 @@ describe('parseQuerySafe', () => { test('parses query string from a Request using a schema', () => { const search = new URLSearchParams({ id: 'id1', age: '10' }); const request = new Request(`http://example.com?${search.toString()}`); - const result = zx.parseQuerySafe(request, schema); + const result = zx.parseQuerySafe(request, zodSchema); expect(result.success).toBe(true); if (result.success !== true) throw new Error('Parsing failed'); expect(result.data).toStrictEqual(queryResult); type verify = Expect>; }); - test('returns an error for invalid query params', () => { + test('returns an error for invalid query params using a schema', () => { const badRequest = new Request(`http://example.com?id=id1&age=notanumber`); - const result = zx.parseQuerySafe(badRequest, schema); + const result = zx.parseQuerySafe(badRequest, zodSchema); expect(result.success).toBe(false); if (result.success !== false) throw new Error('Parsing should have failed'); expect(result.error.issues.length).toBe(1); @@ -257,42 +314,59 @@ describe('parseForm', () => { image?: NodeOnDiskFile; }; const formResult = { id: 'id1', age: 10, consent: true }; - const schema = z.object({ + const objectSchema = { id: z.string(), age: zx.IntAsString, consent: zx.CheckboxAsString, friends: z.array(z.string()).optional(), image: z.instanceof(NodeOnDiskFile).optional(), - }); + }; + const zodSchema = z.object(objectSchema); + const asyncSchema = zodSchema.transform((data) => Promise.resolve(data)); test('parses FormData from Request using an object', async () => { const request = createFormRequest(); - const result = await zx.parseForm(request, { - id: z.string(), - age: zx.IntAsString, - consent: zx.CheckboxAsString, - friends: z.array(z.string()).optional(), - image: z.instanceof(NodeOnDiskFile).optional(), - }); + const result = await zx.parseForm(request, objectSchema); expect(result).toStrictEqual(formResult); type verify = Expect>; }); test('parses FormData from Request using a schema', async () => { const request = createFormRequest(); - const result = await zx.parseForm(request, schema); + const result = await zx.parseForm(request, zodSchema); + expect(result).toStrictEqual(formResult); + type verify = Expect>; + }); + + test('parses FormData from Request using an async schema', async () => { + const request = createFormRequest(); + const result = await zx.parseForm(request, asyncSchema); + expect(result).toStrictEqual(formResult); + type verify = Expect>; + }); + + test('parses FormData from FormData using an object', async () => { + const formData = await createFormRequest().formData(); + const result = await zx.parseForm(formData, objectSchema); expect(result).toStrictEqual(formResult); type verify = Expect>; }); test('parses FormData from FormData using a schema', async () => { const formData = await createFormRequest().formData(); - const result = await zx.parseForm(formData, schema); + const result = await zx.parseForm(formData, zodSchema); expect(result).toStrictEqual(formResult); type verify = Expect>; }); - test('parses arrays from FormData of a Request', async () => { + test('parses FormData from FormData using an async schema', async () => { + const formData = await createFormRequest().formData(); + const result = await zx.parseForm(formData, asyncSchema); + expect(result).toStrictEqual(formResult); + type verify = Expect>; + }); + + test('parses arrays from FormData of a Request using an object', async () => { const form = new FormData(); form.append('id', 'id1'); form.append('age', '10'); @@ -317,15 +391,15 @@ describe('parseForm', () => { type verify = Expect>; }); - test('parses objects keys of FormData from FormData', async () => { + test('parses objects keys of FormData from FormData using a schema', async () => { const request = createFormRequest(); const form = await request.formData(); const image = new NodeOnDiskFile('public/image.jpeg', 'image/jpeg'); form.append('image', image); const parser = getCustomFileParser('image'); - const result = await zx.parseForm( + const result = await zx.parseForm( form, - schema, + zodSchema, { parser } ); expect(result).toStrictEqual({ @@ -335,9 +409,37 @@ describe('parseForm', () => { type verify = Expect>; }); - test('throws for invalid FormData', () => { + test('parses objects keys of FormData from FormData using an async schema', async () => { + const request = createFormRequest(); + const form = await request.formData(); + const image = new NodeOnDiskFile('public/image.jpeg', 'image/jpeg'); + form.append('image', image); + const parser = getCustomFileParser('image'); + const result = await zx.parseForm( + form, + asyncSchema, + { parser } + ); + expect(result).toStrictEqual({ + ...formResult, + image, + }); + type verify = Expect>; + }); + + test('throws for invalid FormData using an object', async () => { + const badRequest = createFormRequest('notanumber'); + expect(() => zx.parseQuery(badRequest, objectSchema)).toThrow(); + }); + + test('throws for invalid FormData using a schema', async () => { const badRequest = createFormRequest('notanumber'); - expect(() => zx.parseQuery(badRequest, schema)).toThrow(); + expect(() => zx.parseQuery(badRequest, zodSchema)).toThrow(); + }); + + test('throws for invalid FormData using an async schema', async () => { + const badRequest = createFormRequest('notanumber'); + expect(() => zx.parseQuery(badRequest, asyncSchema)).toThrow(); }); }); @@ -350,13 +452,14 @@ describe('parseFormSafe', () => { image?: NodeOnDiskFile; }; const formResult = { id: 'id1', age: 10, consent: true }; - const schema = z.object({ + const zodSchema = z.object({ id: z.string(), age: zx.IntAsString, consent: zx.CheckboxAsString, friends: z.array(z.string()).optional(), image: z.instanceof(NodeOnDiskFile).optional(), }); + const asyncSchema = zodSchema.transform((data) => Promise.resolve(data)); test('parses FormData from Request using an object', async () => { const request = createFormRequest(); @@ -375,7 +478,16 @@ describe('parseFormSafe', () => { test('parses FormData from Request using a schema', async () => { const request = createFormRequest(); - const result = await zx.parseFormSafe(request, schema); + const result = await zx.parseFormSafe(request, zodSchema); + expect(result.success).toBe(true); + if (result.success !== true) throw new Error('Parsing failed'); + expect(result.data).toStrictEqual(formResult); + type verify = Expect>; + }); + + test('parses FormData from Request using an async schema', async () => { + const request = createFormRequest(); + const result = await zx.parseFormSafe(request, asyncSchema); expect(result.success).toBe(true); if (result.success !== true) throw new Error('Parsing failed'); expect(result.data).toStrictEqual(formResult); @@ -384,7 +496,16 @@ describe('parseFormSafe', () => { test('parses FormData from FormData using a schema', async () => { const formData = await createFormRequest().formData(); - const result = await zx.parseFormSafe(formData, schema); + const result = await zx.parseFormSafe(formData, zodSchema); + expect(result.success).toBe(true); + if (result.success !== true) throw new Error('Parsing failed'); + expect(result.data).toStrictEqual(formResult); + type verify = Expect>; + }); + + test('parses FormData from FormData using an async schema', async () => { + const formData = await createFormRequest().formData(); + const result = await zx.parseFormSafe(formData, asyncSchema); expect(result.success).toBe(true); if (result.success !== true) throw new Error('Parsing failed'); expect(result.data).toStrictEqual(formResult); @@ -393,22 +514,42 @@ describe('parseFormSafe', () => { test('returns an error for invalid FormData', async () => { const badRequest = createFormRequest('notanumber'); - const result = await zx.parseFormSafe(badRequest, schema); + const result = await zx.parseFormSafe(badRequest, zodSchema); expect(result.success).toBe(false); if (result.success !== false) throw new Error('Parsing should have failed'); expect(result.error.issues.length).toBe(1); expect(result.error.issues[0].path[0]).toBe('age'); }); - test('parses objects keys of FormData from FormData', async () => { + test('parses objects keys of FormData from FormData using a schema', async () => { + const request = createFormRequest(); + const form = await request.formData(); + const image = new NodeOnDiskFile('public/image.jpeg', 'image/jpeg'); + form.append('image', image); + const parser = getCustomFileParser('image'); + const result = await zx.parseFormSafe( + form, + zodSchema, + { parser } + ); + expect(result.success).toBe(true); + if (result.success !== true) throw new Error('Parsing failed'); + expect(result.data).toStrictEqual({ + ...formResult, + image, + }); + type verify = Expect>; + }); + + test('parses objects keys of FormData from FormData using an async schema', async () => { const request = createFormRequest(); const form = await request.formData(); const image = new NodeOnDiskFile('public/image.jpeg', 'image/jpeg'); form.append('image', image); const parser = getCustomFileParser('image'); - const result = await zx.parseFormSafe( + const result = await zx.parseFormSafe( form, - schema, + asyncSchema, { parser } ); expect(result.success).toBe(true); diff --git a/src/parsers.ts b/src/parsers.ts index 9efa9d4..04fd476 100644 --- a/src/parsers.ts +++ b/src/parsers.ts @@ -133,7 +133,7 @@ export async function parseForm< : await request.clone().formData(); const data = await parseFormData(formData, options?.parser); const finalSchema = schema instanceof ZodType ? schema : z.object(schema); - return finalSchema.parse(data); + return finalSchema.parseAsync(data); } catch (error) { throw createErrorResponse(options); } @@ -158,7 +158,7 @@ export async function parseFormSafe< : await request.clone().formData(); const data = await parseFormData(formData, options?.parser); const finalSchema = schema instanceof ZodType ? schema : z.object(schema); - return finalSchema.safeParse(data) as SafeParsedData; + return finalSchema.safeParseAsync(data) as Promise>; } /** @@ -183,10 +183,7 @@ function isObjectEntry([, value]: [string, FormDataEntryValue]) { /** * Get the form data from a request as an object. */ -async function parseFormData( - formData: FormData, - customParser?: SearchParamsParser -) { +function parseFormData(formData: FormData, customParser?: SearchParamsParser) { const objectEntries = [...formData.entries()].filter(isObjectEntry); objectEntries.forEach(([key, value]) => { formData.set(key, JSON.stringify(value));