diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 108ddc8a..9138e4a4 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -27,9 +27,6 @@ type ParsedData = { data: Record | null | undefined; }; -const unionError = - 'FormData parsing failed: Unions are only supported when the dataType option for superForm is set to "json".'; - export async function parseRequest>( data: unknown, schemaData: JSONSchema, @@ -180,7 +177,12 @@ function _parseFormData>( ); } - function parseSingleEntry(key: string, entry: FormDataEntryValue, info: SchemaInfo) { + function parseSingleEntry( + key: string, + entry: FormDataEntryValue, + info: SchemaInfo, + types: Exclude[] + ) { if (options?.preprocessed && options.preprocessed.includes(key as keyof T)) { return entry; } @@ -190,12 +192,19 @@ function _parseFormData>( return !allowFiles ? undefined : entry.size ? entry : info.isNullable ? null : undefined; } - if (info.types.length > 1) { - throw new SchemaError(unionError, key); + let result: unknown; + + for (const type of types) { + result = parseFormDataEntry(key, entry, type, info); + + if (result === unsupportedSchemaType) { + // Skip this candidate. + continue; + } + return result; } - const [type] = info.types; - return parseFormDataEntry(key, entry, type ?? 'any', info); + return result; } const defaultPropertyType = @@ -210,9 +219,8 @@ function _parseFormData>( assertSchema(property, key); - const info = schemaInfo(property ?? defaultPropertyType, !schema.required?.includes(key), [ - key - ]); + const isOptional = !schema.required?.includes(key); + const info = schemaInfo(property ?? defaultPropertyType, isOptional, [key]); if (!info) continue; if (!info.types.includes('boolean') && !schema.additionalProperties && !formData.has(key)) { @@ -221,35 +229,95 @@ function _parseFormData>( const entries = formData.getAll(key); + let candidates = [info]; + if (info.union && info.union.length > 1) { - throw new SchemaError(unionError, key); + candidates = info.union.map((u) => + schemaInfo(u, isOptional || !u.required?.includes(key), [key]) + ); } - if (info.types.includes('array') || info.types.includes('set')) { - // If no items, it could be a union containing the info - const items = property.items ?? (info.union?.length == 1 ? info.union[0] : undefined); - if (!items || typeof items == 'boolean' || (Array.isArray(items) && items.length != 1)) { - throw new SchemaError( - 'Arrays must have a single "items" property that defines its type.', - key - ); - } + let result: { + data: unknown; + triedTypes: string[]; + } = { data: undefined, triedTypes: [] }; + + for (const candidate of candidates) { + if (candidate.types.includes('array') || candidate.types.includes('set')) { + let { items } = candidate.schema; + + if (!items && candidate.union) { + // Find items info in the unions. + items = candidate.union.find( + (u) => u && typeof u !== 'boolean' && Array.isArray(u) && u.length === 1 + ); + } + + if (!items || typeof items === 'boolean' || (Array.isArray(items) && items.length !== 1)) { + throw new SchemaError( + 'Arrays must have a single "items" property that defines its type.', + key + ); + } + + const arrayType = Array.isArray(items) ? items[0] : items; + assertSchema(arrayType, key); + + const arrayInfo = schemaInfo(arrayType, info.isOptional, [key]); + if (!arrayInfo) continue; - const arrayType = Array.isArray(items) ? items[0] : items; - assertSchema(arrayType, key); + const types = arrayInfo.types.length === 0 ? ['any' as const] : arrayInfo.types; - const arrayInfo = schemaInfo(arrayType, info.isOptional, [key]); - if (!arrayInfo) continue; + // Check for empty files being posted (and filtered) + const isFileArray = entries.length && entries.some((e) => e && typeof e !== 'string'); - // Check for empty files being posted (and filtered) - const isFileArray = entries.length && entries.some((e) => e && typeof e !== 'string'); - const arrayData = entries.map((e) => parseSingleEntry(key, e, arrayInfo)); - if (isFileArray && arrayData.every((file) => !file)) arrayData.length = 0; + const arrayData = entries.map((e) => parseSingleEntry(key, e, arrayInfo, types)); - output[key] = info.types.includes('set') ? new Set(arrayData) : arrayData; - } else { - output[key] = parseSingleEntry(key, entries[entries.length - 1], info); + if (isFileArray && arrayData.every((file) => !file)) arrayData.length = 0; + + // Validate this candidate's values. + if (arrayData.find((d) => d === unsupportedSchemaType)) { + // Skip this candidate. + + result = { + data: unsupportedSchemaType, + triedTypes: [...result.triedTypes, ...types] + }; + continue; + } + + result = { + data: info.types.includes('set') ? new Set(arrayData) : arrayData, + triedTypes: [] + }; + break; + } else { + const types = candidate.types.length === 0 ? ['any' as const] : candidate.types; + + const data = parseSingleEntry(key, entries[entries.length - 1], candidate, types); + + if (data === unsupportedSchemaType) { + // Skip this candidate. + + result = { + data: unsupportedSchemaType, + triedTypes: [...result.triedTypes, ...types] + }; + continue; + } + + result = { data, triedTypes: [] }; + break; + } } + + if (result.data === unsupportedSchemaType) { + throw new SuperFormError( + 'Unsupported schema types for FormData: ' + result.triedTypes.join(', ') + ); + } + + output[key] = result.data; } return output; @@ -323,6 +391,8 @@ function parseFormDataEntry( return typeError(); default: - throw new SuperFormError('Unsupported schema type for FormData: ' + type); + return unsupportedSchemaType; } } + +const unsupportedSchemaType = Symbol(); diff --git a/src/routes/(v2)/v2/Navigation.svelte b/src/routes/(v2)/v2/Navigation.svelte index 1176188b..e591751f 100644 --- a/src/routes/(v2)/v2/Navigation.svelte +++ b/src/routes/(v2)/v2/Navigation.svelte @@ -9,6 +9,7 @@ 'defaults-fail', 'dynamic-validators', 'empty-enum', + 'formdata-unions', 'issue-309-unions', 'issue-332-arrays', 'issue-337-checkboxes', diff --git a/src/routes/(v2)/v2/formdata-unions/+page.server.ts b/src/routes/(v2)/v2/formdata-unions/+page.server.ts new file mode 100644 index 00000000..fd93b157 --- /dev/null +++ b/src/routes/(v2)/v2/formdata-unions/+page.server.ts @@ -0,0 +1,25 @@ +import { zod } from '$lib/adapters/zod.js'; +import { fail } from '$lib/index.js'; +import { superValidate } from '$lib/server/index.js'; +import { schema } from './schema.js'; + +export const load = async () => { + const form = await superValidate(zod(schema)); + return { form }; +}; + +export const actions = { + default: async ({ request }) => { + const formData = await request.formData(); + console.log(formData); + + const form = await superValidate(formData, zod(schema)); + console.log(form); + + if (!form.valid) { + return fail(400, { form }); + } + + return { form }; + } +}; diff --git a/src/routes/(v2)/v2/formdata-unions/+page.svelte b/src/routes/(v2)/v2/formdata-unions/+page.svelte new file mode 100644 index 00000000..83791081 --- /dev/null +++ b/src/routes/(v2)/v2/formdata-unions/+page.svelte @@ -0,0 +1,31 @@ + + +
+
+ Input morning, noon or evening + +
+
+ +
+
+ + + + + diff --git a/src/routes/(v2)/v2/formdata-unions/schema.ts b/src/routes/(v2)/v2/formdata-unions/schema.ts new file mode 100644 index 00000000..f4922523 --- /dev/null +++ b/src/routes/(v2)/v2/formdata-unions/schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +const morningSchema = z.literal('morning').transform(() => ({ from: '06:00', to: '11:00' })); + +const noonSchema = z.literal('noon').transform(() => ({ from: '11:00', to: '14:00' })); + +const eveningSchema = z.literal('evening').transform(() => ({ from: '14:00', to: '19:00' })); + +export const schema = z.object({ + time: z.union([morningSchema, noonSchema, eveningSchema]) +}); diff --git a/src/tests/formData.test.ts b/src/tests/formData.test.ts index 23392b26..bd3eadd4 100644 --- a/src/tests/formData.test.ts +++ b/src/tests/formData.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, assert } from 'vitest'; import { parseFormData } from '$lib/formData.js'; import { z } from 'zod'; import * as v from 'valibot'; -import { zodToJSONSchema } from '$lib/adapters/zod.js'; -import { SchemaError } from '$lib/index.js'; +import { zod, zodToJSONSchema } from '$lib/adapters/zod.js'; +import { SchemaError, superValidate } from '$lib/index.js'; import { valibot } from '$lib/adapters/valibot.js'; enum Foo { @@ -78,4 +78,36 @@ describe('FormData parsing', () => { expect(valibot(schema).defaults.urltest).toBe(''); }); + + it('should parse unions', async () => { + const schema = z.object({ + test: z.union([z.literal('one'), z.literal('two')]) + }); + + const formData = new FormData(); + + { + formData.set('test', 'one'); + + const form = await superValidate(formData, zod(schema)); + expect(form.valid).toBe(true); + expect(form.data.test).toBe('one'); + } + + { + formData.set('test', 'two'); + + const form = await superValidate(formData, zod(schema)); + expect(form.valid).toBe(true); + expect(form.data.test).toBe('two'); + } + + { + formData.set('test', 'three'); + + const form = await superValidate(formData, zod(schema)); + expect(form.valid).toBe(false); + expect(form.data.test).toBe('three'); + } + }); });