diff --git a/README.md b/README.md index 78e62d5..9768888 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,65 @@ Parses URLSearchParams to JavaScript objects according to Zod schemas. ## Description -TODO +The set of allowed Zod schemas is restricted to ensure the parsing is unambiguous. +This parser may be used as a true inverse operation to [@seamapi/url-search-params-serializer][@url-search-params-serializer]. + +[@url-search-params-serializer]: https://github.com/seamapi/url-search-params-serializer + +### Generous Parsing + +This parser provides strict compatibility with the serialization format of [@url-search-params-serializer]. +However, some additional input cases are handled: + +- For `z.number()`, `z.boolean()`, `z.date()`, `z.object()`, and `z.record()`, + whitespace only values are parsed as `null`. +- For `z.number()`, `z.boolean()`, `z.date()`, + starting and ending whitespace is trimmed before parsing. +- For `z.boolean()`, the following strings are parsed as `true`: + `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&bar=2` + - `foo[]=1&foo[]=2` + - `foo=1,2` + +### Allowed Zod Schemas + +- The top-level schema must be an `z.object()` or `z.union()` of `z.object()`. +- Properties may be a `z.object()` or `z.union()` of objects. +- All union object types must flatten to a parseable object schema with non-conflicting property types. +- Primitive properties must be a `z.string()`, `z.number()`, `z.boolean()` or `z.date()`. + - Properties must be a single-value type. + - The primitives `z.bigint()` and `z.symbol()` are not supported. + - Strings with zero length are not allowed. + If not specified, a `z.string()` is always assumed to be `z.string().min(1)`. + - Using `z.enum()` is allowed and equivalent to `z.string()`. +- Any property may be `z.optional()` or `z.never()`. +- No property may `z.void()`, `z.undefined()`, `z.any()`, or `z.unknown()`. +- Any property may be `z.nullable()` except `z.array()`. +- Properties that are `z.literal()` are allowed and must still obey all of these rules. +- A `z.array()` must be of a single value-type. + - The value-types must obey all the same basic rules + for primitive object, union, and property types. + - Value-types may not be `z.nullable()` or `z.undefined()`. + - The value-type cannot be a `z.object()`. + - The value-type cannot be an `z.array()` or contain a nested `z.array()` at any level. + - The value-type cannot be a `z.boolean()`. + This restriction is not strictly necessary, + but a deliberate choice not to support such schemas in this version. +- A `z.record()` has less-strict schema constraints but weaker parsing guarantees: + - They keys must be `z.string()`. + - The value-type may be a single primitive type. + - The value-type may be `z.nullable()`. + - The value-type may not be a `z.record()`, `z.array()`, or `z.object()`. + This restriction is not strictly necessary, + but a deliberate choice not to support such schemas in this version. + - The value-type may be a union of primitive types, + but this union must include `z.string()` and all values will be parsed as `z.string()`. + For schemas of this type, the parser is no longer a true inverse of the serialization. ## Installation @@ -19,6 +77,22 @@ $ npm install @seamapi/url-search-params-parser [npm]: https://www.npmjs.com/ +## Usage + +```ts +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' + +parseUrlSearchParams( + 'age=27&isAdmin=true&name=Dax&tags=cars&tags=planes', + z.object({ + name: z.string().min(1), + age: z.number(), + isAdmin: z.boolean(), + tags: z.array(z.string()), + }), +) // => { name: 'Dax', age: 27, isAdmin: true, tags: ['cars', 'planes'] } +``` + ## Development and Testing ### Quickstart diff --git a/examples/index.ts b/examples/index.ts index 7a1cc11..a40e350 100755 --- a/examples/index.ts +++ b/examples/index.ts @@ -2,8 +2,8 @@ import landlubber from 'landlubber' -import * as todo from './todo.js' +import * as parse from './parse.js' -const commands = [todo] +const commands = [parse] await landlubber(commands).parse() diff --git a/examples/parse.ts b/examples/parse.ts new file mode 100644 index 0000000..40947c6 --- /dev/null +++ b/examples/parse.ts @@ -0,0 +1,47 @@ +import type { Builder, Command, Describe, Handler } from 'landlubber' +import { z } from 'zod' + +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' + +interface Options { + query: string +} + +export const command: Command = 'parse query' + +export const describe: Describe = 'Parse query' + +export const builder: Builder = { + query: { + type: 'string', + describe: 'Query string', + }, +} + +export const handler: Handler = async ({ query, logger }) => { + logger.info({ data: parseUrlSearchParams(query, schema) }, 'params') +} + +const schema = z + .object({ + a: z.string(), + b: z.number(), + c: z.boolean(), + d: z.null(), + e: z.array(z.union([z.string(), z.number()])), + f: z.array(z.string()), + g: z.date(), + h: z.date(), + i: z + .object({ + foo: z.number(), + bar: z + .object({ + baz: z.number(), + fizz: z.array(z.union([z.string(), z.number()])), + }) + .optional(), + }) + .optional(), + }) + .optional() diff --git a/examples/todo.ts b/examples/todo.ts deleted file mode 100644 index 6e86ba4..0000000 --- a/examples/todo.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Builder, Command, Describe, Handler } from 'landlubber' - -import { todo } from '@seamapi/url-search-params-parser' - -interface Options { - x: string -} - -export const command: Command = 'todo x' - -export const describe: Describe = 'TODO' - -export const builder: Builder = { - x: { - type: 'string', - default: 'TODO', - describe: 'TODO', - }, -} - -export const handler: Handler = async ({ x, logger }) => { - logger.info({ data: todo(x) }, 'TODO') -} diff --git a/package-lock.json b/package-lock.json index 0779156..3ec7e0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { + "@seamapi/url-search-params-serializer": "^2.0.0-beta.2", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -24,11 +25,15 @@ "tsc-alias": "^1.8.2", "tsup": "^8.0.1", "tsx": "^4.6.2", - "typescript": "~5.3.3" + "typescript": "~5.3.3", + "zod": "^3.24.2" }, "engines": { "node": ">=18.12.0", "npm": ">= 9.0.0" + }, + "peerDependencies": { + "zod": "^3.0.0" } }, "node_modules/@babel/code-frame": { @@ -1145,6 +1150,17 @@ "license": "MIT", "peer": true }, + "node_modules/@seamapi/url-search-params-serializer": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-2.0.0-beta.2.tgz", + "integrity": "sha512-ASHYo5/0IY7iB/cWcZA9b6meAa5b22NfEZyIuZQ2I90L7WeKzeWxEmusA4nfc26giQOeuEF1xAIGSdGrT+lGZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0", + "npm": ">= 9.0.0" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -9397,6 +9413,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 98adc3c..4530dff 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.0.1", "description": "Parses URLSearchParams to JavaScript objects according to Zod schemas.", "type": "module", - "main": "index.js", "types": "index.d.ts", "exports": { ".": { @@ -67,7 +66,11 @@ "node": ">=18.12.0", "npm": ">= 9.0.0" }, + "peerDependencies": { + "zod": "^3.0.0" + }, "devDependencies": { + "@seamapi/url-search-params-serializer": "^2.0.0-beta.2", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -83,6 +86,7 @@ "tsc-alias": "^1.8.2", "tsup": "^8.0.1", "tsx": "^4.6.2", - "typescript": "~5.3.3" + "typescript": "~5.3.3", + "zod": "^3.24.2" } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 0cbac41..5444578 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1 +1 @@ -export { todo } from './todo.js' +export * from './parse.js' diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts new file mode 100644 index 0000000..556d14a --- /dev/null +++ b/src/lib/parse.test.ts @@ -0,0 +1,25 @@ +import test from 'ava' +import { z } from 'zod' + +import { parseUrlSearchParams } from './parse.js' + +test('parseUrlSearchParams: with string input', (t) => { + t.deepEqual( + parseUrlSearchParams( + 'foo=d&bar=2', + z.object({ foo: z.string().optional(), bar: z.number().optional() }), + ), + { foo: 'd', bar: 2 }, + ) +}) + +test('parseUrlSearchParams: with URLSearchParams input', (t) => { + t.deepEqual( + parseUrlSearchParams( + new URLSearchParams('foo=d&bar=2'), + z.object({ foo: z.string().optional(), bar: z.number().optional() }), + ), + { foo: 'd', bar: 2 }, + 'with URLSearchParams input', + ) +}) diff --git a/src/lib/parse.ts b/src/lib/parse.ts new file mode 100644 index 0000000..f2122e1 --- /dev/null +++ b/src/lib/parse.ts @@ -0,0 +1,90 @@ +import type { ZodSchema } from 'zod' + +import { + type ParamSchema, + type ValueType, + zodSchemaToParamSchema, +} from './schema.js' +import { isZodObject } from './zod.js' + +export const parseUrlSearchParams = ( + query: URLSearchParams | string, + schema: ZodSchema, +): Record => { + const searchParams = + typeof query === 'string' ? new URLSearchParams(query) : query + + if (!isZodObject(schema)) { + throw new Error( + 'The Zod schema to parse URL search params must be an ZodObject schema', + ) + } + + const paramSchema = zodSchemaToParamSchema(schema) + return parseFromParamSchema(searchParams, paramSchema, []) as Record< + string, + unknown + > +} + +const parseFromParamSchema = ( + searchParams: URLSearchParams, + node: ParamSchema | ValueType, + path: string[], +): Record | unknown => { + if (typeof node === 'string') { + const key = path.join('.') + return parse(key, searchParams.getAll(key), node) + } + + const entries = Object.entries(node).reduce< + Array<[string, Record | unknown]> + >((acc, entry) => { + const [k, v] = entry + const currentPath = [...path, k] + return [...acc, [k, parseFromParamSchema(searchParams, v, currentPath)]] + }, []) + + return Object.fromEntries(entries) +} + +const parse = (k: string, values: string[], type: ValueType): unknown => { + if (values.length === 0) return undefined + + if (values[0] == null) { + throw new Error(`Unexpected nil value when parsing ${k}`) + } + + if (type === 'number') return parseNumber(values[0].trim()) + if (type === 'boolean') return parseBoolean(values[0].trim()) + if (type === 'string') return String(values[0]) + if (type === 'string_array') return values.map((v) => String(v)) + if (type === 'number_array') return values.map((v) => parseNumber(v)) + throw new UnparseableSearchParamError(k, 'unsupported type') +} + +const parseNumber = (v: string): number | null | string => { + if (v.length === 0) return null + if (v === 'Infinity' || v === '-Infinity') return v + const n = Number(v) + if (isNaN(n)) return v + if (n === Infinity || n === -Infinity) return v + return n +} + +const truthyValues = ['true', 'True', 'TRUE', 'yes', 'Yes', 'YES', '1'] +const falsyValues = ['false', 'False', 'FALSE', 'no', 'No', 'NO', '0'] + +const parseBoolean = (v: string): boolean | null | string => { + if (v.length === 0) return null + if (truthyValues.includes(v)) return true + if (falsyValues.includes(v)) return false + return v +} + +export class UnparseableSearchParamError extends Error { + constructor(name: string, message: string) { + super(`Could not parse parameter: '${name}' ${message}`) + this.name = this.constructor.name + } +} diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts new file mode 100644 index 0000000..c838a19 --- /dev/null +++ b/src/lib/schema.test.ts @@ -0,0 +1,80 @@ +import test from 'ava' +import { z } from 'zod' + +import { UnparseableSchemaError, zodSchemaToParamSchema } from './schema.js' + +test('zodSchemaToParamSchema: parses flat object schemas', (t) => { + t.deepEqual(zodSchemaToParamSchema(z.object({ foo: z.string() })), { + foo: 'string', + }) + t.deepEqual( + zodSchemaToParamSchema( + z.object({ + a: z.string(), + b: z.number(), + c: z.boolean(), + d: z.array(z.string()), + }), + ), + { + a: 'string', + b: 'number', + c: 'boolean', + d: 'string_array', + }, + ) +}) + +test('zodSchemaToParamSchema: parses nested object schemas', (t) => { + t.deepEqual(zodSchemaToParamSchema(z.object({ foo: z.string() })), { + foo: 'string', + }) + t.deepEqual( + zodSchemaToParamSchema( + z.object({ + a: z.string(), + b: z.object({ + c: z.boolean(), + d: z.array(z.string()), + e: z.object({ + f: z.boolean(), + }), + }), + }), + ), + { + a: 'string', + b: { + c: 'boolean', + d: 'string_array', + e: { + f: 'boolean', + }, + }, + }, + ) +}) + +test('zodSchemaToParamSchema: cannot parse non-object schemas', (t) => { + t.throws(() => zodSchemaToParamSchema(z.number()), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => zodSchemaToParamSchema(z.enum(['foo'])), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => zodSchemaToParamSchema(z.string()), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => zodSchemaToParamSchema(z.map(z.string(), z.string())), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => zodSchemaToParamSchema(z.array(z.string())), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => zodSchemaToParamSchema(z.null()), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => zodSchemaToParamSchema(z.union([z.number(), z.string()])), { + instanceOf: UnparseableSchemaError, + }) +}) diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..a4533ba --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,87 @@ +// TODO: unsupported types (parsing error): +// bigint: strings that are too big for Number +// any other arrays types, e.g., boolean_array, null_array +// arrays with mixed value types +// arrays containing object schemas or other arrays + +import type { ZodTypeAny } from 'zod' + +import { + isZodArray, + isZodBoolean, + isZodNumber, + isZodObject, + isZodSchema, + isZodString, +} from './zod.js' + +export type ValueType = + | 'string' + | 'number' + | 'boolean' + | 'date' + | 'string_array' + | 'number_array' + | 'date_array' + | 'string_record' + | 'number_record' + | 'boolean_record' + | 'date_record' + +export interface ParamSchema { + [key: string]: ParamSchema | ValueType +} + +export const zodSchemaToParamSchema = (schema: ZodTypeAny): ParamSchema => { + if (!isZodObject(schema)) { + throw new UnparseableSchemaError( + [], + 'top level schema must be an object schema', + ) + } + const paramSchema = nestedZodSchemaToParamSchema(schema, []) + if (typeof paramSchema === 'string') { + throw new Error('Expected ParamSchema not ValueType') + } + return paramSchema +} + +const nestedZodSchemaToParamSchema = ( + schema: ZodTypeAny, + path: string[], +): ParamSchema | ValueType => { + if (isZodObject(schema)) { + const shape = schema.shape as unknown as Record + + const entries = Object.entries(shape).reduce< + Array<[string, ParamSchema | ValueType]> + >((acc, entry) => { + const [k, v] = entry + const currentPath = [...path, k] + if (isZodSchema(v)) { + return [...acc, [k, nestedZodSchemaToParamSchema(v, currentPath)]] + } + throw new UnparseableSchemaError(currentPath, 'unexpected non-zod schema') + }, []) + + return Object.fromEntries(entries) + } + + if (isZodNumber(schema)) return 'number' + if (isZodString(schema)) return 'string' + if (isZodBoolean(schema)) return 'boolean' + if (isZodArray(schema)) { + // TODO: handle number_array + return 'string_array' + } + + throw new UnparseableSchemaError(path, `schema type is not supported`) +} + +export class UnparseableSchemaError extends Error { + constructor(path: string[], message: string) { + const part = path.length === 0 ? '' : ` at ${path.join('.')}` + super(`Could not parse Zod schema${part}: ${message}`) + this.name = this.constructor.name + } +} diff --git a/src/lib/todo.test.ts b/src/lib/todo.test.ts deleted file mode 100644 index 4f4b033..0000000 --- a/src/lib/todo.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import test from 'ava' - -import { todo } from './todo.js' - -test('todo: returns argument', (t) => { - t.is(todo('todo'), 'todo', 'returns input') -}) diff --git a/src/lib/todo.ts b/src/lib/todo.ts deleted file mode 100644 index 5633fe7..0000000 --- a/src/lib/todo.ts +++ /dev/null @@ -1 +0,0 @@ -export const todo = (x: string): string => x diff --git a/src/lib/zod.ts b/src/lib/zod.ts new file mode 100644 index 0000000..4036c43 --- /dev/null +++ b/src/lib/zod.ts @@ -0,0 +1,47 @@ +import { + type ZodArray, + type ZodBoolean, + ZodFirstPartyTypeKind, + type ZodNumber, + type ZodObject, + type ZodTypeAny, +} from 'zod' + +export const isZodObject = ( + schema: ZodTypeAny, +): schema is ZodObject => { + return schema._def.typeName === ZodFirstPartyTypeKind.ZodObject +} + +export const isZodArray = ( + schema: ZodTypeAny, +): schema is ZodArray => { + return schema._def.typeName === ZodFirstPartyTypeKind.ZodArray +} + +export const isZodString = (schema: ZodTypeAny): schema is ZodBoolean => { + return ( + schema._def.typeName === ZodFirstPartyTypeKind.ZodString || + schema._def.innerType?._def.typeName === ZodFirstPartyTypeKind.ZodString + ) +} + +export const isZodBoolean = (schema: ZodTypeAny): schema is ZodBoolean => { + return ( + schema._def.typeName === ZodFirstPartyTypeKind.ZodBoolean || + schema._def.innerType?._def.typeName === ZodFirstPartyTypeKind.ZodBoolean + ) +} + +export const isZodNumber = (schema: ZodTypeAny): schema is ZodNumber => { + return ( + schema._def.typeName === ZodFirstPartyTypeKind.ZodNumber || + schema._def.innerType?._def.typeName === ZodFirstPartyTypeKind.ZodNumber + ) +} + +export const isZodSchema = (schema: unknown): schema is ZodTypeAny => { + if (schema == null) return false + if (typeof schema !== 'object') return false + return '_def' in schema +} diff --git a/test/bijection.test.ts b/test/bijection.test.ts new file mode 100644 index 0000000..d53a8e8 --- /dev/null +++ b/test/bijection.test.ts @@ -0,0 +1,42 @@ +import { + type Params, + serializeUrlSearchParams, +} from '@seamapi/url-search-params-serializer' +import test from 'ava' +import { z, type ZodSchema } from 'zod' + +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' + +const bijection = test.macro({ + title(providedTitle) { + return `parses ${providedTitle}` + }, + exec(t, input: Params, schema: ZodSchema) { + t.deepEqual( + parseUrlSearchParams(serializeUrlSearchParams(input), schema), + input, + ) + }, +}) + +test( + 'empty params', + bijection, + { + foo: undefined, + }, + z.object({ foo: z.string() }), +) + +test( + 'nested params', + bijection, + { + foo: 'a', + bar: { baz: 'b' }, + }, + z.object({ + foo: z.string(), + bar: z.object({ baz: z.string() }), + }), +) diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts new file mode 100644 index 0000000..206e304 --- /dev/null +++ b/test/edge-cases.test.ts @@ -0,0 +1,38 @@ +import test from 'ava' +import { z } from 'zod' + +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' + +test('pass though number values that do not parse as number', (t) => { + t.deepEqual(parseUrlSearchParams('foo=a', z.object({ foo: z.number() })), { + foo: 'a', + }) + t.deepEqual(parseUrlSearchParams('foo=NaN', z.object({ foo: z.number() })), { + foo: 'NaN', + }) + t.deepEqual( + parseUrlSearchParams('foo=Infinity', z.object({ foo: z.number() })), + { + foo: 'Infinity', + }, + ) + t.deepEqual( + parseUrlSearchParams('foo=-Infinity', z.object({ foo: z.number() })), + { + foo: '-Infinity', + }, + ) +}) + +test('pass though boolean values that do not parse as truthy or falsy values', (t) => { + t.deepEqual(parseUrlSearchParams('foo=a', z.object({ foo: z.boolean() })), { + foo: 'a', + }) + t.deepEqual(parseUrlSearchParams('foo=tRue', z.object({ foo: z.number() })), { + foo: 'tRue', + }) +}) + +// e.g., foo.bar= would conflict with foo.bar.a= or foo.bar.b=2 +// since this would be a null object containing values (null is still a value). +test.todo('cannot parse conflicting object keys') diff --git a/test/generous-parsing.test.ts b/test/generous-parsing.test.ts new file mode 100644 index 0000000..c47897b --- /dev/null +++ b/test/generous-parsing.test.ts @@ -0,0 +1,54 @@ +import test from 'ava' +import { z, type ZodSchema } from 'zod' + +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' + +const parseEmptyOrWhitespace = test.macro({ + title(providedTitle) { + return `parses empty or whitespace ${providedTitle} params as null` + }, + 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) + }, +}) + +test('number', parseEmptyOrWhitespace, z.number()) +test('boolean', parseEmptyOrWhitespace, z.boolean()) + +test.todo('parse empty or whitespace boolean params as null') +test.todo('parse empty or whitespace date params as null') +test.todo('parse empty or whitespace object params as null') +test.todo('parse empty or whitespace record params as null') + +test.todo('trim whitespace before parsing number params') +test.todo('trim whitespace before parsing boolean params') +test.todo('trim whitespace before parsing date params') + +test.todo('parse empty or whitespace array params as empty') +test.todo( + 'cannot parse multiple empty or whitespace array params like foo=&foo=', +) +test.todo( + 'cannot parse mixed empty or whitespace array params like foo=&foo=bar', +) + +test.todo('parse additional strings as true and false') + +test.todo('parse repeated array params like foo=bar&foo=baz') +test.todo('parse bracket array params like foo[]=bar&foo[]=baz') +test.todo('parse comma array params like foo=bar,baz') + +test.todo('cannot parse mixed array params like foo=bar,baz&foo=bar&foo[]=baz') +test.todo('cannot parse array values containing a comma like foo=a,b&foo=b,c') +test.todo( + 'cannot parse array values containing a comma like foo[]=a,b&foo[]=b,c', +) diff --git a/test/todo.test.ts b/test/todo.test.ts deleted file mode 100644 index c6c5156..0000000 --- a/test/todo.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import test from 'ava' - -import { todo } from '@seamapi/url-search-params-parser' - -test('todo: returns argument', (t) => { - t.is(todo('todo'), 'todo', 'returns input') -})