diff --git a/README.md b/README.md index 10828c2..c390c87 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,29 @@ This parser may be used as a true inverse operation to [@seamapi/url-search-para [@url-search-params-serializer]: https://github.com/seamapi/url-search-params-serializer +### Strict Parsing + +By default, or when passing `strict: true`, +the parser only parses the expected output of [@url-search-params-serializer], +making the parser a true inverse of the serializer: + +- Parses `z.array()` only in the repeated format `foo=1&foo=2`. + - Array values may contain a `,` and are never split, + e.g., `foo=a,b&foo=c` is parsed as `['a,b', 'c']`. + - There is no bracket array format: + since the serializer never outputs it, + a param named `foo[]` is unrelated to the param `foo` + and is parsed as a param literally named `foo[]`. +- For `z.boolean()`, only the strings `true` and `false` are parsed. +- Whitespace is significant and is never trimmed: + only a completely empty value is parsed as `null` + (or as the empty array for `z.array()`). + ### Generous Parsing -This parser provides strict compatibility with the serialization format of [@url-search-params-serializer]. -However, some additional input cases are handled: +When passing `strict: false`, additional input cases are handled +at the cost of some limitations, e.g., +array string values containing a `,` are not supported: - For `z.number()`, `z.boolean()`, `z.date()`, `z.object()`, and `z.record()`, whitespace only values are parsed as `null`. @@ -25,6 +44,17 @@ However, some additional input cases are handled: `true`, `True`, `TRUE`, `yes`, `Yes`, `YES`, and `1`. - For `z.boolean()`, the following values are parsed as `false`: `false`, `False`, `FALSE`, `no`, `No`, `NO`, and `0`. +- Parses `z.array()` in the following formats. + In order to support unambiguous parsing, array string values + containing a `,` are not supported. + - `foo=1&foo=2` + - `foo[]=1&foo[]=2` + - `foo=1,2` + +### Parsing in Both Modes + +These rules apply in strict and generous mode alike: + - For `z.number()`, `z.boolean()`, and `z.date()`, values that cannot be parsed as the expected type are passed through unchanged as strings, e.g., `foo=a` is parsed as `'a'` for `z.number()`. @@ -34,12 +64,6 @@ However, some additional input cases are handled: Whitespace is significant and is never trimmed for `z.string()`. - For `z.object()` and `z.record()`, a non-empty value is passed through unchanged as a string, e.g., `foo=a` is parsed as `'a'`. -- Parses `z.array()` in the following formats. - In order to support unambiguous parsing, array string values - containing a `,` are not supported. - - `foo=1&foo=2` - - `foo[]=1&foo[]=2` - - `foo=1,2` - Search params not present in the schema are ignored. ### Unparseable Search Params @@ -48,19 +72,22 @@ Some inputs are ambiguous and cannot be parsed unambiguously. These throw an `UnparseableSearchParamError`: - A non-array param with repeated values, e.g., `foo=1&foo=2` for `z.number()`. -- An array param that mixes array formats, - e.g., `foo=1&foo[]=2` or `foo=1,2&foo=3`. -- An array param that repeats a value containing a `,`, - e.g., `foo=a,b&foo=c,d`. -- An array param using the bracket format with a value containing a `,`, - e.g., `foo[]=a,b`. - An array param that mixes empty values with other values, - e.g., `foo=&foo=1`, `foo=&foo=`, or `foo=a,,b`. + e.g., `foo=&foo=1` or `foo=&foo=`. - An object or record param that conflicts with its own nested params, e.g., `foo.bar=&foo.bar.a=1`, since this would be a null object containing a value. - A param nested inside a record param, e.g., `foo.a.b=1` for `z.record(z.string(), z.number())`. +- In generous mode (`strict: false`): + - An array param that mixes array formats, + e.g., `foo=1&foo[]=2` or `foo=1,2&foo=3`. + - An array param that repeats a value containing a `,`, + e.g., `foo=a,b&foo=c,d`. + - An array param using the bracket format with a value containing a `,`, + e.g., `foo[]=a,b`. + - An array param using the comma format with empty values, + e.g., `foo=a,,b`. Schemas that do not obey these rules throw an `UnparseableSchemaError`. @@ -132,6 +159,21 @@ parseUrlSearchParams( ) // => { name: 'Dax', age: 27, isAdmin: true, tags: ['cars', 'planes'] } ``` +Pass `strict: false` to enable [generous parsing](#generous-parsing), +which accepts additional input formats +at the cost of no longer being a true inverse of the serializer. + +```ts +parseUrlSearchParams( + 'isAdmin=yes&tags=cars,planes', + z.object({ + isAdmin: z.boolean(), + tags: z.array(z.string()), + }), + { strict: false }, +) // => { isAdmin: true, tags: ['cars', 'planes'] } +``` + This parser does not validate its output: pass the parsed params to the schema to both validate and type them. diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 0946b99..e0e13a8 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -10,6 +10,11 @@ import { // Value types that are parsed from a single search param value. type LeafType = PrimitiveType | 'null' +// How array values containing a comma are handled: +// split into the array (the comma array format), +// rejected as unparseable, or kept verbatim. +type CommaHandling = 'split' | 'reject' | 'verbatim' + const arrayElementTypes: Partial> = { string_array: 'string', number_array: 'number', @@ -23,16 +28,31 @@ const recordElementTypes: Partial> = { date_record: 'date', } +export interface ParseUrlSearchParamsOptions { + /** + * When true, the default, only parse the expected output of + * @seamapi/url-search-params-serializer, making the parser + * a true inverse of the serializer. + * When false, enable generous parsing: additional input formats + * are accepted, at the cost of some limitations, + * e.g., array values may not contain a comma. + */ + strict?: boolean +} + export const parseUrlSearchParams = ( query: URLSearchParams | string, schema: ZodSchema, + options: ParseUrlSearchParamsOptions = {}, ): Record => { + const { strict = true } = options + const searchParams = typeof query === 'string' ? new URLSearchParams(query) : query const paramSchema = zodSchemaToParamSchema(schema) - return parseFromParamSchema(searchParams, paramSchema, []) as Record< + return parseFromParamSchema(searchParams, paramSchema, [], strict) as Record< string, unknown > @@ -42,9 +62,10 @@ const parseFromParamSchema = ( searchParams: URLSearchParams, node: ParamSchema | ValueType, path: string[], + strict: boolean, ): unknown => { if (typeof node === 'string') { - return parseValueType(searchParams, node, path) + return parseValueType(searchParams, node, path, strict) } const name = path.join('.') @@ -53,13 +74,13 @@ const parseFromParamSchema = ( // e.g., foo= for the schema z.object({ foo: z.object({ bar: z.string() }) }). if (path.length > 0 && searchParams.has(name)) { assertNoNestedParams(searchParams, name) - return parseNestedValue(searchParams, name) + return parseNestedValue(searchParams, name, strict) } const entries = Object.entries(node).reduce>( (acc, [k, v]) => [ ...acc, - [k, parseFromParamSchema(searchParams, v, [...path, k])], + [k, parseFromParamSchema(searchParams, v, [...path, k], strict)], ], [], ) @@ -71,6 +92,7 @@ const parseValueType = ( searchParams: URLSearchParams, type: ValueType, path: string[], + strict: boolean, ): unknown => { const name = path.join('.') @@ -79,12 +101,12 @@ const parseValueType = ( const arrayElementType = arrayElementTypes[type] if (arrayElementType != null) { - return parseArrayParam(searchParams, name, arrayElementType) + return parseArrayParam(searchParams, name, arrayElementType, strict) } const recordElementType = recordElementTypes[type] if (recordElementType != null) { - return parseRecordParam(searchParams, name, recordElementType) + return parseRecordParam(searchParams, name, recordElementType, strict) } const values = searchParams.getAll(name) @@ -99,15 +121,28 @@ const parseValueType = ( ) } - return parseLeaf(value, type as LeafType) + return parseLeaf(value, type as LeafType, strict) } const parseArrayParam = ( searchParams: URLSearchParams, name: string, elementType: LeafType, + strict: boolean, ): unknown => { const repeatedValues = searchParams.getAll(name) + + // The serializer only outputs the repeated array format, + // so in strict mode a param named foo[] is unrelated to the param foo: + // it is a param literally named "foo[]". + if (strict) { + if (repeatedValues.length === 0) return undefined + return parseArrayValues(name, repeatedValues, elementType, { + commaHandling: 'verbatim', + strict, + }) + } + const bracketName = `${name}[]` const bracketValues = searchParams.getAll(bracketName) @@ -119,24 +154,30 @@ const parseArrayParam = ( } if (bracketValues.length > 0) { - return parseArrayValues(bracketName, bracketValues, elementType, false) + return parseArrayValues(bracketName, bracketValues, elementType, { + commaHandling: 'reject', + strict, + }) } if (repeatedValues.length === 0) return undefined - return parseArrayValues(name, repeatedValues, elementType, true) + return parseArrayValues(name, repeatedValues, elementType, { + commaHandling: 'split', + strict, + }) } const parseArrayValues = ( name: string, values: string[], elementType: LeafType, - allowCommaFormat: boolean, + { commaHandling, strict }: { commaHandling: CommaHandling; strict: boolean }, ): unknown[] => { // The serialization of the empty array is a single empty value. - if (values.length === 1 && isBlank(values[0] ?? '')) return [] + if (values.length === 1 && isEmpty(values[0] ?? '', strict)) return [] - if (values.some(isBlank)) { + if (values.some((v) => isEmpty(v, strict))) { throw new UnparseableSearchParamError( name, 'mixes empty values with other values', @@ -145,8 +186,8 @@ const parseArrayValues = ( const [value] = values - if (values.some((v) => v.includes(','))) { - if (!allowCommaFormat) { + if (commaHandling !== 'verbatim' && values.some((v) => v.includes(','))) { + if (commaHandling === 'reject') { throw new UnparseableSearchParamError( name, 'uses the bracket array format with a value containing a comma ","', @@ -163,27 +204,28 @@ const parseArrayValues = ( const parts = value.split(',') - if (parts.some(isBlank)) { + if (parts.some((v) => isEmpty(v, strict))) { throw new UnparseableSearchParamError( name, 'uses the comma array format with one or more empty values', ) } - return parts.map((v) => parseLeaf(v, elementType)) + return parts.map((v) => parseLeaf(v, elementType, strict)) } - return values.map((v) => parseLeaf(v, elementType)) + return values.map((v) => parseLeaf(v, elementType, strict)) } const parseRecordParam = ( searchParams: URLSearchParams, name: string, elementType: LeafType, + strict: boolean, ): unknown => { if (searchParams.has(name)) { assertNoNestedParams(searchParams, name) - return parseNestedValue(searchParams, name) + return parseNestedValue(searchParams, name, strict) } const prefix = `${name}.` @@ -196,7 +238,9 @@ const parseRecordParam = ( const entries = keys.map<[string, unknown]>((k) => { const recordKey = k.slice(prefix.length) - if (recordKey.includes('.') || recordKey.endsWith('[]')) { + // In strict mode there is no bracket array format, + // so a record key ending in [] is a literal record key. + if (recordKey.includes('.') || (!strict && recordKey.endsWith('[]'))) { throw new UnparseableSearchParamError( k, 'is nested inside a record parameter, ' + @@ -216,7 +260,7 @@ const parseRecordParam = ( ) } - return [recordKey, parseLeaf(value, elementType)] + return [recordKey, parseLeaf(value, elementType, strict)] }) return Object.fromEntries(entries) @@ -227,6 +271,7 @@ const parseRecordParam = ( const parseNestedValue = ( searchParams: URLSearchParams, name: string, + strict: boolean, ): unknown => { const values = searchParams.getAll(name) const [value] = values @@ -240,7 +285,7 @@ const parseNestedValue = ( ) } - if (isBlank(value)) return null + if (isEmpty(value, strict)) return null return value } @@ -260,16 +305,31 @@ const assertNoNestedParams = ( } } -const parseLeaf = (value: string, type: LeafType): unknown => { +const parseLeaf = (value: string, type: LeafType, strict: boolean): unknown => { // Zero-length strings are not serializable, so an empty value is null. if (type === 'string') return value.length === 0 ? null : value + if (strict) { + if (value.length === 0) return null + + // The serializer never pads values with whitespace, + // so pass such values through unchanged. + if (value.trim() !== value) return value + + if (type === 'number') return parseNumber(value) + if (type === 'boolean') return parseStrictBoolean(value) + if (type === 'date') return parseDate(value) + + // A null param has no other parseable value, so pass the value through. + return value + } + const trimmed = value.trim() if (trimmed.length === 0) return null if (type === 'number') return parseNumber(trimmed) - if (type === 'boolean') return parseBoolean(trimmed) + if (type === 'boolean') return parseGenerousBoolean(trimmed) if (type === 'date') return parseDate(trimmed) // A null param has no other parseable value, so pass the value through. @@ -287,19 +347,29 @@ const parseNumber = (v: string): number | string => { const truthyValues = ['true', 'True', 'TRUE', 'yes', 'Yes', 'YES', '1'] const falsyValues = ['false', 'False', 'FALSE', 'no', 'No', 'NO', '0'] -const parseBoolean = (v: string): boolean | string => { +const parseGenerousBoolean = (v: string): boolean | string => { if (truthyValues.includes(v)) return true if (falsyValues.includes(v)) return false return v } +// The serializer only outputs the strings true and false. +const parseStrictBoolean = (v: string): boolean | string => { + if (v === 'true') return true + if (v === 'false') return false + return v +} + const parseDate = (v: string): Date | string => { const date = new Date(v) if (isNaN(date.getTime())) return v return date } -const isBlank = (v: string): boolean => v.trim().length === 0 +// The serializer never outputs whitespace-only values, +// so they are only treated as empty when parsing generously. +const isEmpty = (v: string, strict: boolean): boolean => + strict ? v.length === 0 : v.trim().length === 0 export class UnparseableSearchParamError extends Error { constructor(name: string, message: string) { diff --git a/test/bijection.test.ts b/test/bijection.test.ts index 0c490d7..ba33e5d 100644 --- a/test/bijection.test.ts +++ b/test/bijection.test.ts @@ -316,12 +316,35 @@ test( test( 'array values containing a comma', - notInvertible, - { foo: ['a,b'] }, + bijection, + { + foo: ['a,b', 'c,d'], + }, z.object({ foo: z.array(z.string()) }), - { foo: ['a', 'b'] }, ) +test( + 'params literally named with a bracket suffix', + bijection, + { + 'foo[]': 'a', + bar: ['b', 'c'], + }, + z.object({ 'foo[]': z.string(), bar: z.array(z.string()) }), +) + +// When parsing generously, a single array value containing a comma +// is parsed using the comma array format. +test('does not invert array values containing a comma when strict is false', (t) => { + const schema = z.object({ foo: z.array(z.string()) }) + t.deepEqual( + parseUrlSearchParams(serializeUrlSearchParams({ foo: ['a,b'] }), schema, { + strict: false, + }), + { foo: ['a', 'b'] }, + ) +}) + test( 'the empty object, which is serialized as undefined', notInvertible, diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index fc2576b..9397e35 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -131,7 +131,9 @@ test('cannot parse nested params inside a record', (t) => { t.throws(() => parseUrlSearchParams('foo.a.b=1', schema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo.a[]=1', schema), { + // In strict mode, a[] is a literal record key, + // but in generous mode it is the unsupported bracket array format. + t.throws(() => parseUrlSearchParams('foo.a[]=1', schema, { strict: false }), { instanceOf: UnparseableSearchParamError, }) }) diff --git a/test/generous-parsing.test.ts b/test/generous-parsing.test.ts index 3412afc..96a3ed6 100644 --- a/test/generous-parsing.test.ts +++ b/test/generous-parsing.test.ts @@ -6,6 +6,10 @@ import { UnparseableSearchParamError, } from '@seamapi/url-search-params-parser' +// Generous parsing must be enabled with strict: false. +const parse = (query: string, schema: ZodSchema): unknown => + parseUrlSearchParams(query, schema, { strict: false }) + const parseEmptyOrWhitespace = test.macro({ title(providedTitle) { return `parses empty or whitespace ${providedTitle} params as null` @@ -13,14 +17,14 @@ const parseEmptyOrWhitespace = test.macro({ exec(t, type: ZodSchema) { const schema = z.object({ foo: type }) const expected = { foo: null } - t.deepEqual(parseUrlSearchParams('foo=', schema), expected) - t.deepEqual(parseUrlSearchParams('foo= ', schema), expected) - t.deepEqual(parseUrlSearchParams('foo= ', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=%20', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=%20%20%20', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=+', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=+++', schema), expected) - t.deepEqual(parseUrlSearchParams('foo= %20 ++ +%20 ', schema), expected) + t.deepEqual(parse('foo=', schema), expected) + t.deepEqual(parse('foo= ', schema), expected) + t.deepEqual(parse('foo= ', schema), expected) + t.deepEqual(parse('foo=%20', schema), expected) + t.deepEqual(parse('foo=%20%20%20', schema), expected) + t.deepEqual(parse('foo=+', schema), expected) + t.deepEqual(parse('foo=+++', schema), expected) + t.deepEqual(parse('foo= %20 ++ +%20 ', schema), expected) }, }) @@ -36,20 +40,20 @@ const trimBeforeParsing = test.macro({ }, exec(t, type: ZodSchema, value: string, expected: unknown) { const schema = z.object({ foo: type }) - t.deepEqual(parseUrlSearchParams(`foo=${value}`, schema), { foo: expected }) - t.deepEqual(parseUrlSearchParams(`foo= ${value}`, schema), { + t.deepEqual(parse(`foo=${value}`, schema), { foo: expected }) + t.deepEqual(parse(`foo= ${value}`, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo=${value} `, schema), { + t.deepEqual(parse(`foo=${value} `, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo= ${value} `, schema), { + t.deepEqual(parse(`foo= ${value} `, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo=%20${value}%20`, schema), { + t.deepEqual(parse(`foo=%20${value}%20`, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo=+++${value}+++`, schema), { + t.deepEqual(parse(`foo=+++${value}+++`, schema), { foo: expected, }) }, @@ -67,15 +71,15 @@ test( test('does not trim whitespace before parsing string params', (t) => { const schema = z.object({ foo: z.string() }) - t.deepEqual(parseUrlSearchParams('foo=+bar+', schema), { foo: ' bar ' }) - t.deepEqual(parseUrlSearchParams('foo=+', schema), { foo: ' ' }) + t.deepEqual(parse('foo=+bar+', schema), { foo: ' bar ' }) + t.deepEqual(parse('foo=+', schema), { foo: ' ' }) }) test('parses additional strings as true', (t) => { const schema = z.object({ foo: z.boolean() }) for (const value of ['true', 'True', 'TRUE', 'yes', 'Yes', 'YES', '1']) { t.deepEqual( - parseUrlSearchParams(`foo=${value}`, schema), + parse(`foo=${value}`, schema), { foo: true }, `parses ${value} as true`, ) @@ -86,7 +90,7 @@ test('parses additional strings as false', (t) => { const schema = z.object({ foo: z.boolean() }) for (const value of ['false', 'False', 'FALSE', 'no', 'No', 'NO', '0']) { t.deepEqual( - parseUrlSearchParams(`foo=${value}`, schema), + parse(`foo=${value}`, schema), { foo: false }, `parses ${value} as false`, ) @@ -97,104 +101,103 @@ const arraySchema = z.object({ foo: z.array(z.string()) }) const numberArraySchema = z.object({ foo: z.array(z.number()) }) test('parses repeated array params like foo=bar&foo=baz', (t) => { - t.deepEqual(parseUrlSearchParams('foo=bar&foo=baz', arraySchema), { + t.deepEqual(parse('foo=bar&foo=baz', arraySchema), { foo: ['bar', 'baz'], }) - t.deepEqual(parseUrlSearchParams('foo=bar', arraySchema), { foo: ['bar'] }) - t.deepEqual(parseUrlSearchParams('foo=1&foo=2', numberArraySchema), { + t.deepEqual(parse('foo=bar', arraySchema), { foo: ['bar'] }) + t.deepEqual(parse('foo=1&foo=2', numberArraySchema), { foo: [1, 2], }) }) test('parses bracket array params like foo[]=bar&foo[]=baz', (t) => { - t.deepEqual(parseUrlSearchParams('foo[]=bar&foo[]=baz', arraySchema), { + t.deepEqual(parse('foo[]=bar&foo[]=baz', arraySchema), { foo: ['bar', 'baz'], }) - t.deepEqual(parseUrlSearchParams('foo[]=bar', arraySchema), { foo: ['bar'] }) - t.deepEqual(parseUrlSearchParams('foo[]=1&foo[]=2', numberArraySchema), { + t.deepEqual(parse('foo[]=bar', arraySchema), { foo: ['bar'] }) + t.deepEqual(parse('foo[]=1&foo[]=2', numberArraySchema), { foo: [1, 2], }) }) test('parses comma array params like foo=bar,baz', (t) => { - t.deepEqual(parseUrlSearchParams('foo=bar,baz', arraySchema), { + t.deepEqual(parse('foo=bar,baz', arraySchema), { foo: ['bar', 'baz'], }) - t.deepEqual(parseUrlSearchParams('foo=1,2', numberArraySchema), { + t.deepEqual(parse('foo=1,2', numberArraySchema), { foo: [1, 2], }) }) test('parses empty or whitespace array params as empty', (t) => { const expected = { foo: [] } - t.deepEqual(parseUrlSearchParams('foo=', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo= ', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo=%20', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo=+++', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo[]=', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo[]=+++', arraySchema), expected) + t.deepEqual(parse('foo=', arraySchema), expected) + t.deepEqual(parse('foo= ', arraySchema), expected) + t.deepEqual(parse('foo=%20', arraySchema), expected) + t.deepEqual(parse('foo=+++', arraySchema), expected) + t.deepEqual(parse('foo[]=', arraySchema), expected) + t.deepEqual(parse('foo[]=+++', arraySchema), expected) }) test('cannot parse multiple empty or whitespace array params like foo=&foo=', (t) => { - t.throws(() => parseUrlSearchParams('foo=&foo=', arraySchema), { + t.throws(() => parse('foo=&foo=', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=+&foo=%20', arraySchema), { + t.throws(() => parse('foo=+&foo=%20', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo[]=&foo[]=', arraySchema), { + t.throws(() => parse('foo[]=&foo[]=', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse mixed empty or whitespace array params like foo=&foo=bar', (t) => { - t.throws(() => parseUrlSearchParams('foo=&foo=bar', arraySchema), { + t.throws(() => parse('foo=&foo=bar', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar&foo=', arraySchema), { + t.throws(() => parse('foo=bar&foo=', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar&foo=+++', arraySchema), { + t.throws(() => parse('foo=bar&foo=+++', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo[]=&foo[]=bar', arraySchema), { + t.throws(() => parse('foo[]=&foo[]=bar', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,,baz', arraySchema), { + t.throws(() => parse('foo=bar,,baz', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,', arraySchema), { + t.throws(() => parse('foo=bar,', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse mixed array params like foo=bar,baz&foo=bar&foo[]=baz', (t) => { - t.throws( - () => parseUrlSearchParams('foo=bar,baz&foo=bar&foo[]=baz', arraySchema), - { instanceOf: UnparseableSearchParamError }, - ) - t.throws(() => parseUrlSearchParams('foo=bar&foo[]=baz', arraySchema), { + t.throws(() => parse('foo=bar,baz&foo=bar&foo[]=baz', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parse('foo=bar&foo[]=baz', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,baz&foo[]=fizz', arraySchema), { + t.throws(() => parse('foo=bar,baz&foo[]=fizz', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,baz&foo=fizz', arraySchema), { + t.throws(() => parse('foo=bar,baz&foo=fizz', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse array values containing a comma like foo=a,b&foo=b,c', (t) => { - t.throws(() => parseUrlSearchParams('foo=a,b&foo=b,c', arraySchema), { + t.throws(() => parse('foo=a,b&foo=b,c', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse array values containing a comma like foo[]=a,b&foo[]=b,c', (t) => { - t.throws(() => parseUrlSearchParams('foo[]=a,b&foo[]=b,c', arraySchema), { + t.throws(() => parse('foo[]=a,b&foo[]=b,c', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo[]=a,b', arraySchema), { + t.throws(() => parse('foo[]=a,b', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) diff --git a/test/strict-parsing.test.ts b/test/strict-parsing.test.ts new file mode 100644 index 0000000..c2c1623 --- /dev/null +++ b/test/strict-parsing.test.ts @@ -0,0 +1,133 @@ +import test from 'ava' +import { z } from 'zod' + +import { + parseUrlSearchParams, + UnparseableSearchParamError, +} from '@seamapi/url-search-params-parser' + +// Strict parsing is the default and only parses +// the expected output of the serializer. + +const arraySchema = z.object({ foo: z.array(z.string()) }) + +test('strict: parses repeated array params', (t) => { + t.deepEqual(parseUrlSearchParams('foo=bar&foo=baz', arraySchema), { + foo: ['bar', 'baz'], + }) + t.deepEqual(parseUrlSearchParams('foo=bar', arraySchema), { foo: ['bar'] }) + t.deepEqual(parseUrlSearchParams('foo=', arraySchema), { foo: [] }) +}) + +test('strict: does not split array values containing a comma', (t) => { + t.deepEqual(parseUrlSearchParams('foo=a,b', arraySchema), { foo: ['a,b'] }) + t.deepEqual(parseUrlSearchParams('foo=a,b&foo=c,d', arraySchema), { + foo: ['a,b', 'c,d'], + }) + t.deepEqual(parseUrlSearchParams('foo=a,b&foo=c', arraySchema), { + foo: ['a,b', 'c'], + }) +}) + +test('strict: there is no bracket array format', (t) => { + // A param named foo[] is unrelated to the array param foo, + // exactly as the serializer treats them. + t.deepEqual(parseUrlSearchParams('foo[]=bar', arraySchema), { + foo: undefined, + }) + t.deepEqual(parseUrlSearchParams('foo[]=bar&foo[]=baz', arraySchema), { + foo: undefined, + }) + t.deepEqual(parseUrlSearchParams('foo=bar&foo[]=baz', arraySchema), { + foo: ['bar'], + }) +}) + +test('strict: parses params literally named with a bracket suffix', (t) => { + t.deepEqual( + parseUrlSearchParams('foo[]=bar', z.object({ 'foo[]': z.string() })), + { 'foo[]': 'bar' }, + ) + t.deepEqual( + parseUrlSearchParams( + 'foo[]=bar&foo[]=baz', + z.object({ 'foo[]': z.array(z.string()) }), + ), + { 'foo[]': ['bar', 'baz'] }, + ) + t.deepEqual( + parseUrlSearchParams( + 'foo.a[]=1', + z.object({ foo: z.record(z.string(), z.number()) }), + ), + { foo: { 'a[]': 1 } }, + ) +}) + +test('strict: cannot parse arrays mixing empty values with other values', (t) => { + t.throws(() => parseUrlSearchParams('foo=&foo=', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parseUrlSearchParams('foo=&foo=bar', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) +}) + +test('strict: only parses true and false as booleans', (t) => { + const schema = z.object({ foo: z.boolean() }) + t.deepEqual(parseUrlSearchParams('foo=true', schema), { foo: true }) + t.deepEqual(parseUrlSearchParams('foo=false', schema), { foo: false }) + for (const value of ['True', 'TRUE', 'yes', '1', 'False', 'NO', '0']) { + t.deepEqual( + parseUrlSearchParams(`foo=${value}`, schema), + { foo: value }, + `passes ${value} through unchanged`, + ) + } +}) + +test('strict: does not trim whitespace before parsing', (t) => { + t.deepEqual(parseUrlSearchParams('foo=+2+', z.object({ foo: z.number() })), { + foo: ' 2 ', + }) + t.deepEqual( + parseUrlSearchParams('foo=+true', z.object({ foo: z.boolean() })), + { foo: ' true' }, + ) +}) + +test('strict: parses whitespace-only values as whitespace, not null', (t) => { + t.deepEqual(parseUrlSearchParams('foo=+', z.object({ foo: z.number() })), { + foo: ' ', + }) + t.deepEqual(parseUrlSearchParams('foo=%20', z.object({ foo: z.boolean() })), { + foo: ' ', + }) + t.deepEqual(parseUrlSearchParams('foo=+', arraySchema), { foo: [' '] }) +}) + +test('strict: parses empty values as null', (t) => { + t.deepEqual(parseUrlSearchParams('foo=', z.object({ foo: z.number() })), { + foo: null, + }) + t.deepEqual(parseUrlSearchParams('foo=', z.object({ foo: z.string() })), { + foo: null, + }) + t.deepEqual( + parseUrlSearchParams( + 'foo=', + z.object({ foo: z.record(z.string(), z.string()) }), + ), + { foo: null }, + ) +}) + +test('strict: record values keep commas and are not trimmed', (t) => { + t.deepEqual( + parseUrlSearchParams( + 'foo.a=x,y&foo.b=+z', + z.object({ foo: z.record(z.string(), z.string()) }), + ), + { foo: { a: 'x,y', b: ' z' } }, + ) +}) diff --git a/test/zod-v4.test.ts b/test/zod-v4.test.ts index 00facf8..b5fbe98 100644 --- a/test/zod-v4.test.ts +++ b/test/zod-v4.test.ts @@ -4,14 +4,18 @@ import { z } from 'zod-v4' import { parseUrlSearchParams, + type ParseUrlSearchParamsOptions, UnparseableSchemaError, UnparseableSearchParamError, } from '@seamapi/url-search-params-parser' // The parser inspects Zod internals, which changed between Zod v3 and v4. // These tests mirror the core parsing behavior using schemas built with Zod v4. -const parse = (query: string, schema: unknown): unknown => - parseUrlSearchParams(query, schema as ZodSchema) +const parse = ( + query: string, + schema: unknown, + options?: ParseUrlSearchParamsOptions, +): unknown => parseUrlSearchParams(query, schema as ZodSchema, options) test('zod-v4: parses primitive types', (t) => { const schema = z.object({ @@ -49,12 +53,15 @@ test('zod-v4: parses optional, nullable, default, and readonly wrappers', (t) => }) }) -test('zod-v4: parses arrays in all three formats', (t) => { +test('zod-v4: parses arrays in all three formats when strict is false', (t) => { const schema = z.object({ foo: z.array(z.string()) }) t.deepEqual(parse('foo=a&foo=b', schema), { foo: ['a', 'b'] }) - t.deepEqual(parse('foo[]=a&foo[]=b', schema), { foo: ['a', 'b'] }) - t.deepEqual(parse('foo=a,b', schema), { foo: ['a', 'b'] }) t.deepEqual(parse('foo=', schema), { foo: [] }) + t.deepEqual(parse('foo[]=a&foo[]=b', schema, { strict: false }), { + foo: ['a', 'b'], + }) + t.deepEqual(parse('foo=a,b', schema, { strict: false }), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo=a,b', schema), { foo: ['a,b'] }) }) test('zod-v4: parses number arrays', (t) => { @@ -112,7 +119,7 @@ test('zod-v4: parses enums of numeric TypeScript enums as numbers', (t) => { test('zod-v4: parses enum arrays', (t) => { const schema = z.object({ foo: z.array(z.enum(['a', 'b'])) }) - t.deepEqual(parse('foo=a,b', schema), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo=a,b', schema, { strict: false }), { foo: ['a', 'b'] }) }) test('zod-v4: parses literals', (t) => { @@ -155,10 +162,10 @@ test('zod-v4: parses null and never properties', (t) => { test('zod-v4: throws UnparseableSearchParamError on ambiguous input', (t) => { const schema = z.object({ foo: z.array(z.string()) }) - t.throws(() => parse('foo=a&foo[]=b', schema), { + t.throws(() => parse('foo=a&foo[]=b', schema, { strict: false }), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parse('foo[]=a,b', schema), { + t.throws(() => parse('foo[]=a,b', schema, { strict: false }), { instanceOf: UnparseableSearchParamError, }) })