From f73d27e20b86f8c06a945174612703bee496a597 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 22:51:33 -0800 Subject: [PATCH 01/32] Add @seamapi/url-search-params-serializer for testing --- package-lock.json | 12 ++++++++++++ package.json | 1 + 2 files changed, 13 insertions(+) diff --git a/package-lock.json b/package-lock.json index 0779156..e851d91 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": "^1.2.0", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -1145,6 +1146,17 @@ "license": "MIT", "peer": true }, + "node_modules/@seamapi/url-search-params-serializer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-1.2.0.tgz", + "integrity": "sha512-u7yb+hK+kv05FHg8MrOI5v2O0tty25vB/iTvyKBI28utbhZyNSsHtHIVlOMnzrlD9X8mCuwkNxc1hePJSw6Dwg==", + "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", diff --git a/package.json b/package.json index 98adc3c..a5dd986 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "npm": ">= 9.0.0" }, "devDependencies": { + "@seamapi/url-search-params-serializer": "^1.2.0", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", From 6261ad087ce2715bfe5a6b879e499fec125d32fe Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 22:51:54 -0800 Subject: [PATCH 02/32] Add zod for testing --- package-lock.json | 13 ++++++++++++- package.json | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e851d91..6bb139b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "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", @@ -9409,6 +9410,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 a5dd986..432c1f5 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,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" } } From 2ad9d57bcf8d75ec7fdf27ab88e8e98773770488 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 23:19:45 -0800 Subject: [PATCH 03/32] Add parseUrlSearchParams with failing tests --- examples/index.ts | 4 ++-- examples/parse.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ examples/todo.ts | 23 --------------------- package.json | 3 +++ src/lib/index.ts | 2 +- src/lib/parse.test.ts | 25 +++++++++++++++++++++++ src/lib/parse.ts | 10 +++++++++ src/lib/todo.test.ts | 7 ------- src/lib/todo.ts | 1 - test/parse.test.ts | 14 +++++++++++++ test/todo.test.ts | 7 ------- 11 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 examples/parse.ts delete mode 100644 examples/todo.ts create mode 100644 src/lib/parse.test.ts create mode 100644 src/lib/parse.ts delete mode 100644 src/lib/todo.test.ts delete mode 100644 src/lib/todo.ts create mode 100644 test/parse.test.ts delete mode 100644 test/todo.test.ts 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.json b/package.json index 432c1f5..4d713fb 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,9 @@ "node": ">=18.12.0", "npm": ">= 9.0.0" }, + "peerDependencies": { + "zod": "^3.0.0" + }, "devDependencies": { "@seamapi/url-search-params-serializer": "^1.2.0", "@types/node": "^20.8.10", 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..c2b4cc8 --- /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.failing('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.failing('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..6fc6060 --- /dev/null +++ b/src/lib/parse.ts @@ -0,0 +1,10 @@ +import type { ZodSchema } from 'zod' + +export const parseUrlSearchParams = ( + _query: URLSearchParams | string, + _schema: ZodSchema, +): Record => { + // const _searchParams = + // typeof query === 'string' ? new URLSearchParams(query) : query + return {} +} 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/test/parse.test.ts b/test/parse.test.ts new file mode 100644 index 0000000..c9fdeed --- /dev/null +++ b/test/parse.test.ts @@ -0,0 +1,14 @@ +import { serializeUrlSearchParams } from '@seamapi/url-search-params-serializer' +import test from 'ava' +import { z } from 'zod' + +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' + +test('parses empty string', (t) => { + const schema = z.object({ foo: z.string() }) + const input = {} + t.deepEqual( + parseUrlSearchParams(serializeUrlSearchParams(input), schema), + input, + ) +}) 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') -}) From 11e98caa2761520f66184285b762f9f0ba10b471 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Tue, 25 Feb 2025 07:20:04 +0000 Subject: [PATCH 04/32] ci: Generate code --- package-lock.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6bb139b..8f6702f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,9 @@ "engines": { "node": ">=18.12.0", "npm": ">= 9.0.0" + }, + "peerDependencies": { + "zod": "^3.0.0" } }, "node_modules/@babel/code-frame": { From 3308bc4a17af097aab677f50c5ac4d00b376e140 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Tue, 25 Feb 2025 00:18:03 -0800 Subject: [PATCH 05/32] Add basic parsing implementation --- src/lib/parse.test.ts | 8 +++---- src/lib/parse.ts | 52 ++++++++++++++++++++++++++++++++++++++----- src/lib/zod.ts | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/lib/zod.ts diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index c2b4cc8..72fe783 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -3,23 +3,23 @@ import { z } from 'zod' import { parseUrlSearchParams } from './parse.js' -test.failing('parseUrlSearchParams: with string input', (t) => { +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 }, + { foo: 'd', bar: '2' }, ) }) -test.failing('parseUrlSearchParams: with URLSearchParams input', (t) => { +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 }, + { foo: 'd', bar: '2' }, 'with URLSearchParams input', ) }) diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 6fc6060..2922b6a 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -1,10 +1,50 @@ -import type { ZodSchema } from 'zod' +import type { ZodTypeAny } from 'zod' + +import { + isZodArray, + isZodBoolean, + isZodNumber, + isZodObject, + isZodSchema, + isZodString, +} from './zod.js' export const parseUrlSearchParams = ( - _query: URLSearchParams | string, - _schema: ZodSchema, + query: URLSearchParams | string, + schema: ZodTypeAny, ): Record => { - // const _searchParams = - // typeof query === 'string' ? new URLSearchParams(query) : query - return {} + 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 obj: Record = {} + for (const [k, v] of Object.entries( + schema.shape as unknown as Record, + )) { + if (searchParams.has(k)) { + if (isZodSchema(v)) obj[k] = parse(k, searchParams.getAll(k), v) + } + } + + return obj +} + +const parse = (k: string, values: string[], schema: ZodTypeAny): unknown => { + if (isZodNumber(schema)) return values[0] + if (isZodBoolean(schema)) return values[0] + if (isZodString(schema)) return values[0] + if (isZodArray(schema)) return values + throw new UnparseableSearchParamError(k, 'unsupported type') +} + +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/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 +} From 5dfd48e85e085da14ffe9a0947e35993317c398f Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Tue, 25 Feb 2025 00:26:47 -0800 Subject: [PATCH 06/32] Add basic coercion --- src/lib/parse.test.ts | 4 ++-- src/lib/parse.ts | 7 ++++--- test/parse.test.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index 72fe783..556d14a 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -9,7 +9,7 @@ test('parseUrlSearchParams: with string input', (t) => { 'foo=d&bar=2', z.object({ foo: z.string().optional(), bar: z.number().optional() }), ), - { foo: 'd', bar: '2' }, + { foo: 'd', bar: 2 }, ) }) @@ -19,7 +19,7 @@ test('parseUrlSearchParams: with URLSearchParams input', (t) => { new URLSearchParams('foo=d&bar=2'), z.object({ foo: z.string().optional(), bar: z.number().optional() }), ), - { foo: 'd', bar: '2' }, + { foo: 'd', bar: 2 }, 'with URLSearchParams input', ) }) diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 2922b6a..45485bb 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -35,9 +35,10 @@ export const parseUrlSearchParams = ( } const parse = (k: string, values: string[], schema: ZodTypeAny): unknown => { - if (isZodNumber(schema)) return values[0] - if (isZodBoolean(schema)) return values[0] - if (isZodString(schema)) return values[0] + // TODO: Add better errors with coercion. If coercion fails, passthough? + if (isZodNumber(schema)) return Number(values[0]) + if (isZodBoolean(schema)) return values[0] === 'true' + if (isZodString(schema)) return String(values[0]) if (isZodArray(schema)) return values throw new UnparseableSearchParamError(k, 'unsupported type') } diff --git a/test/parse.test.ts b/test/parse.test.ts index c9fdeed..4d80294 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' -test('parses empty string', (t) => { +test('parses empty params', (t) => { const schema = z.object({ foo: z.string() }) const input = {} t.deepEqual( From af489b8da65edd4344bb25596b0144282ebf3469 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Tue, 25 Feb 2025 18:27:34 -0800 Subject: [PATCH 07/32] Add suggested strategy with zodSchemaToParamSchema --- src/lib/parse.ts | 10 ++++++++++ src/lib/schema.ts | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 src/lib/schema.ts diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 45485bb..f22edc0 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -22,6 +22,16 @@ export const parseUrlSearchParams = ( ) } + // TODO: + // const paramSchema = zodSchemaToParamSchema(schema) + // traverse paramSchema, and build a new object + // for each node, lookup expected key in searchParams + // if match, try to parse and include in object, otherwise, skip node + + // TODO: For array parsing, try to lookup foo=, then foo[]= patterns, + // if only one match, try to detect commas, otherwise ignore commas. + // if both foo= and foo[]= this is a parse error + const obj: Record = {} for (const [k, v] of Object.entries( schema.shape as unknown as Record, diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..996ffa3 --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,23 @@ +// 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' + +type ValueType = + | 'string' + | 'number' + | 'boolean' + | 'date' + | 'string_array' + | 'number_array' + +interface ParamSchema { + [key: string]: ParamSchema | ValueType +} + +export const zodSchemaToParamSchema = (_schema: ZodTypeAny): ParamSchema => { + return {} +} From 4e39fd8d7f3ac36c80a5997953264b3545d30771 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Feb 2025 17:36:36 -0800 Subject: [PATCH 08/32] Add UnparseableSchemaError --- src/lib/schema.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 996ffa3..0f5d2db 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -6,6 +6,8 @@ import type { ZodTypeAny } from 'zod' +import { isZodObject } from './zod.js' + type ValueType = | 'string' | 'number' @@ -18,6 +20,18 @@ interface ParamSchema { [key: string]: ParamSchema | ValueType } -export const zodSchemaToParamSchema = (_schema: ZodTypeAny): ParamSchema => { +export const zodSchemaToParamSchema = (schema: ZodTypeAny): ParamSchema => { + if (!isZodObject(schema)) { + throw new UnparseableSchemaError( + 'top level schema must be an object schema', + ) + } return {} } + +export class UnparseableSchemaError extends Error { + constructor(message: string) { + super(`Could not parse Zod schema: ${message}`) + this.name = this.constructor.name + } +} From d9904920b51e7a1467067587139673d118cd8db1 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Feb 2025 18:30:37 -0800 Subject: [PATCH 09/32] Implement basic zodSchemaToParamSchema --- src/lib/parse.ts | 1 - src/lib/schema.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++ src/lib/schema.ts | 51 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/lib/schema.test.ts diff --git a/src/lib/parse.ts b/src/lib/parse.ts index f22edc0..c8f32f1 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -22,7 +22,6 @@ export const parseUrlSearchParams = ( ) } - // TODO: // const paramSchema = zodSchemaToParamSchema(schema) // traverse paramSchema, and build a new object // for each node, lookup expected key in searchParams diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts new file mode 100644 index 0000000..27b6089 --- /dev/null +++ b/src/lib/schema.test.ts @@ -0,0 +1,50 @@ +import test from 'ava' +import { z } from 'zod' + +import { UnparseableSchemaError, zodSchemaToParamSchema } from './schema.js' + +test('parse 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('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 index 0f5d2db..5128506 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -6,7 +6,14 @@ import type { ZodTypeAny } from 'zod' -import { isZodObject } from './zod.js' +import { + isZodArray, + isZodBoolean, + isZodNumber, + isZodObject, + isZodSchema, + isZodString, +} from './zod.js' type ValueType = | 'string' @@ -23,15 +30,51 @@ interface ParamSchema { export const zodSchemaToParamSchema = (schema: ZodTypeAny): ParamSchema => { if (!isZodObject(schema)) { throw new UnparseableSchemaError( + [], 'top level schema must be an object schema', ) } - return {} + 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(message: string) { - super(`Could not parse Zod schema: ${message}`) + 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 } } From 2295f48d7e1d14eab8c85256756478ddafa15647 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Feb 2025 18:55:25 -0800 Subject: [PATCH 10/32] Use zodSchemaToParamSchema in parseUrlSearchParams --- src/lib/parse.ts | 71 +++++++++++++++++++++++++++------------------- src/lib/schema.ts | 6 ++-- test/parse.test.ts | 7 ++--- 3 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/lib/parse.ts b/src/lib/parse.ts index c8f32f1..ae9d63e 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -1,13 +1,11 @@ import type { ZodTypeAny } from 'zod' import { - isZodArray, - isZodBoolean, - isZodNumber, - isZodObject, - isZodSchema, - isZodString, -} from './zod.js' + type ParamSchema, + type ValueType, + zodSchemaToParamSchema, +} from './schema.js' +import { isZodObject } from './zod.js' export const parseUrlSearchParams = ( query: URLSearchParams | string, @@ -22,33 +20,48 @@ export const parseUrlSearchParams = ( ) } - // const paramSchema = zodSchemaToParamSchema(schema) - // traverse paramSchema, and build a new object - // for each node, lookup expected key in searchParams - // if match, try to parse and include in object, otherwise, skip node - - // TODO: For array parsing, try to lookup foo=, then foo[]= patterns, - // if only one match, try to detect commas, otherwise ignore commas. - // if both foo= and foo[]= this is a parse error - - const obj: Record = {} - for (const [k, v] of Object.entries( - schema.shape as unknown as Record, - )) { - if (searchParams.has(k)) { - if (isZodSchema(v)) obj[k] = parse(k, searchParams.getAll(k), v) - } + 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') { + // TODO: For array parsing, try to lookup foo=, then foo[]= patterns, + // if only one match, try to detect commas, otherwise ignore commas. + // if both foo= and foo[]= this is a parse error + // more generally, try to find a matching key for this node in the searchParams + // and throw if conflicting keys are found, e.g, both foo= and foo[]= + const key = path.join('.') + return parse(key, searchParams.getAll(key), node) } - return obj + 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[], schema: ZodTypeAny): unknown => { +const parse = (k: string, values: string[], type: ValueType): unknown => { // TODO: Add better errors with coercion. If coercion fails, passthough? - if (isZodNumber(schema)) return Number(values[0]) - if (isZodBoolean(schema)) return values[0] === 'true' - if (isZodString(schema)) return String(values[0]) - if (isZodArray(schema)) return values + // TODO: Is this Number parsing safe? + if (values.length === 0) return undefined + if (type === 'number') return Number(values[0]) + if (type === 'boolean') return values[0] === 'true' + if (type === 'string') return String(values[0]) + if (type === 'string_array') return values + if (type === 'number_array') return values.map((v) => Number(v)) throw new UnparseableSearchParamError(k, 'unsupported type') } diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 5128506..14b2bcb 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -15,7 +15,7 @@ import { isZodString, } from './zod.js' -type ValueType = +export type ValueType = | 'string' | 'number' | 'boolean' @@ -23,7 +23,7 @@ type ValueType = | 'string_array' | 'number_array' -interface ParamSchema { +export interface ParamSchema { [key: string]: ParamSchema | ValueType } @@ -47,6 +47,7 @@ const nestedZodSchemaToParamSchema = ( ): 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) => { @@ -57,6 +58,7 @@ const nestedZodSchemaToParamSchema = ( } throw new UnparseableSchemaError(currentPath, 'unexpected non-zod schema') }, []) + return Object.fromEntries(entries) } diff --git a/test/parse.test.ts b/test/parse.test.ts index 4d80294..3804e16 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -7,8 +7,7 @@ import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' test('parses empty params', (t) => { const schema = z.object({ foo: z.string() }) const input = {} - t.deepEqual( - parseUrlSearchParams(serializeUrlSearchParams(input), schema), - input, - ) + t.deepEqual(parseUrlSearchParams(serializeUrlSearchParams(input), schema), { + foo: undefined, + }) }) From ebecd706b4e4822f1ee32701a6a7dba4f8b2e361 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Feb 2025 19:01:57 -0800 Subject: [PATCH 11/32] Test nesting --- package-lock.json | 8 ++++---- package.json | 2 +- src/lib/schema.test.ts | 34 ++++++++++++++++++++++++++++++++-- test/parse.test.ts | 12 ++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f6702f..f2b6610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { - "@seamapi/url-search-params-serializer": "^1.2.0", + "@seamapi/url-search-params-serializer": "^1.3.0", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -1151,9 +1151,9 @@ "peer": true }, "node_modules/@seamapi/url-search-params-serializer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-1.2.0.tgz", - "integrity": "sha512-u7yb+hK+kv05FHg8MrOI5v2O0tty25vB/iTvyKBI28utbhZyNSsHtHIVlOMnzrlD9X8mCuwkNxc1hePJSw6Dwg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-1.3.0.tgz", + "integrity": "sha512-SyS2ioYQx/WlOvcWK1le7iCmGGIIFiLPg5edXyOYEFPiItZVYvQpy1PjafTD1WNFrEaHShbQQo33IllYr7kOeg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 4d713fb..0391d56 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "zod": "^3.0.0" }, "devDependencies": { - "@seamapi/url-search-params-serializer": "^1.2.0", + "@seamapi/url-search-params-serializer": "^1.3.0", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts index 27b6089..c838a19 100644 --- a/src/lib/schema.test.ts +++ b/src/lib/schema.test.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { UnparseableSchemaError, zodSchemaToParamSchema } from './schema.js' -test('parse flat object schemas', (t) => { +test('zodSchemaToParamSchema: parses flat object schemas', (t) => { t.deepEqual(zodSchemaToParamSchema(z.object({ foo: z.string() })), { foo: 'string', }) @@ -25,7 +25,37 @@ test('parse flat object schemas', (t) => { ) }) -test('cannot parse non-object schemas', (t) => { +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, }) diff --git a/test/parse.test.ts b/test/parse.test.ts index 3804e16..549640f 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -11,3 +11,15 @@ test('parses empty params', (t) => { foo: undefined, }) }) + +test('parses nested params', (t) => { + const schema = z.object({ + foo: z.string(), + bar: z.object({ baz: z.string() }), + }) + const input = { foo: 'a', bar: { baz: 'b' } } + t.deepEqual(parseUrlSearchParams(serializeUrlSearchParams(input), schema), { + foo: 'a', + bar: { baz: 'b' }, + }) +}) From be0f99058b88ec5f40c20d80f0abd41b4f07c2bb Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Tue, 4 Mar 2025 10:42:32 -0800 Subject: [PATCH 12/32] Add parsing rules --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 78e62d5..431b935 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,34 @@ Parses URLSearchParams to JavaScript objects according to Zod schemas. ## Description -TODO +### 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 an `z.array()` or contain a nested `z.array()` at any level. +- 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 a union of primitives. + This union must include `z.string()` + and all values will be parsed as `z.string()`. + - The value-type may be `z.nullable()`. + - The value-type may not be a `z.record()`, `z.array()`, or `z.object()`. ## Installation From 9533aa4131ee7b9b451e1cfa7695896c81c3853b Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Tue, 4 Mar 2025 18:50:40 +0000 Subject: [PATCH 13/32] ci: Format code --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 431b935..cf2b0ae 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,18 @@ Parses URLSearchParams to JavaScript objects according to Zod schemas. - 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 an `z.array()` or contain a nested `z.array()` at any level. + - 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 an `z.array()` or contain a nested `z.array()` at any level. - 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 a union of primitives. - This union must include `z.string()` - and all values will be parsed as `z.string()`. - - The value-type may be `z.nullable()`. - - The value-type may not be a `z.record()`, `z.array()`, or `z.object()`. + - They keys must be `z.string()`. + - The value-type may be a single primitive type. + - The value-type may be a union of primitives. + This union must include `z.string()` + and all values will be parsed as `z.string()`. + - The value-type may be `z.nullable()`. + - The value-type may not be a `z.record()`, `z.array()`, or `z.object()`. ## Installation From 45d311ad41ac5e8f11bbb4d9e2353e22f9f547c2 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Tue, 4 Mar 2025 10:57:11 -0800 Subject: [PATCH 14/32] Add TODO --- src/lib/parse.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/parse.ts b/src/lib/parse.ts index ae9d63e..8141002 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -42,6 +42,10 @@ const parseFromParamSchema = ( return parse(key, searchParams.getAll(key), node) } + // TODO: Ensure that there are no conflicting object keys, 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). + const entries = Object.entries(node).reduce< Array<[string, Record | unknown]> >((acc, entry) => { From 753923dc927697bf956be4f1033874a564ee9748 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 17:08:09 -0700 Subject: [PATCH 15/32] Update to url-search-params-serializer v2 --- package-lock.json | 8 ++++---- package.json | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2b6610..6cb1eea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { - "@seamapi/url-search-params-serializer": "^1.3.0", + "@seamapi/url-search-params-serializer": "^2.0.0-beta.0", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -1151,9 +1151,9 @@ "peer": true }, "node_modules/@seamapi/url-search-params-serializer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-1.3.0.tgz", - "integrity": "sha512-SyS2ioYQx/WlOvcWK1le7iCmGGIIFiLPg5edXyOYEFPiItZVYvQpy1PjafTD1WNFrEaHShbQQo33IllYr7kOeg==", + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-2.0.0-beta.1.tgz", + "integrity": "sha512-kZmSKiplYbykVbCppQBYipg5lpk0dr4ux++DVMlVatu+RSRI6CRMCS30vqnv0bzFHlp/9LpKJFh0gIjHnD8Ztg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 0391d56..2de07c7 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": { ".": { @@ -71,7 +70,7 @@ "zod": "^3.0.0" }, "devDependencies": { - "@seamapi/url-search-params-serializer": "^1.3.0", + "@seamapi/url-search-params-serializer": "^2.0.0-beta.0", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", From 40ffb394a3ccd481ec637537be591f857743bb44 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 17:25:58 -0700 Subject: [PATCH 16/32] Extend README --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cf2b0ae..093a2d4 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,18 @@ Parses URLSearchParams to JavaScript objects according to Zod schemas. ## Description +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]: https://github.com/seamapi/url-search-params-serializer + ### 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 + - 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)`. @@ -26,15 +31,18 @@ Parses URLSearchParams to JavaScript objects according to Zod schemas. - 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. - 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 a union of primitives. - This union must include `z.string()` - and all values will be parsed as `z.string()`. - 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 From 078380857b97148d097760591259a82b7def5a7f Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 17:29:30 -0700 Subject: [PATCH 17/32] Add usage --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 093a2d4..5287c64 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,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 From 96a4f8ab529c1a7af90d22cf5a9f4a05819c111c Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Fri, 28 Mar 2025 00:29:57 +0000 Subject: [PATCH 18/32] ci: Format code --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5287c64..362b56e 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ parseUrlSearchParams( name: z.string().min(1), age: z.number(), isAdmin: z.boolean(), - tags: z.array(z.string()) - }) + tags: z.array(z.string()), + }), ) // => { name: 'Dax', age: 27, isAdmin: true, tags: ['cars', 'planes'] } ``` From 51456156d7088a68483ccefb828dfd35fc54ff6e Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 17:30:47 -0700 Subject: [PATCH 19/32] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 362b56e..72e42db 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Parses URLSearchParams to JavaScript objects according to Zod schemas. ## Description 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]. +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 From 24b9cb5d5c28dfaa2fb44099afe62ddb49c530f9 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 20:25:35 -0700 Subject: [PATCH 20/32] Use test macro --- package-lock.json | 8 ++++---- package.json | 2 +- src/lib/parse.ts | 4 ++-- test/parse.test.ts | 51 ++++++++++++++++++++++++++++++---------------- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6cb1eea..3ec7e0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { - "@seamapi/url-search-params-serializer": "^2.0.0-beta.0", + "@seamapi/url-search-params-serializer": "^2.0.0-beta.2", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -1151,9 +1151,9 @@ "peer": true }, "node_modules/@seamapi/url-search-params-serializer": { - "version": "2.0.0-beta.1", - "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-2.0.0-beta.1.tgz", - "integrity": "sha512-kZmSKiplYbykVbCppQBYipg5lpk0dr4ux++DVMlVatu+RSRI6CRMCS30vqnv0bzFHlp/9LpKJFh0gIjHnD8Ztg==", + "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": { diff --git a/package.json b/package.json index 2de07c7..4530dff 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "zod": "^3.0.0" }, "devDependencies": { - "@seamapi/url-search-params-serializer": "^2.0.0-beta.0", + "@seamapi/url-search-params-serializer": "^2.0.0-beta.2", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 8141002..e2b784b 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -1,4 +1,4 @@ -import type { ZodTypeAny } from 'zod' +import type { ZodSchema } from 'zod' import { type ParamSchema, @@ -9,7 +9,7 @@ import { isZodObject } from './zod.js' export const parseUrlSearchParams = ( query: URLSearchParams | string, - schema: ZodTypeAny, + schema: ZodSchema, ): Record => { const searchParams = typeof query === 'string' ? new URLSearchParams(query) : query diff --git a/test/parse.test.ts b/test/parse.test.ts index 549640f..6eef0ad 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -1,25 +1,42 @@ -import { serializeUrlSearchParams } from '@seamapi/url-search-params-serializer' +import { + type Params, + serializeUrlSearchParams, +} from '@seamapi/url-search-params-serializer' import test from 'ava' -import { z } from 'zod' +import { z, type ZodSchema } from 'zod' import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' -test('parses empty params', (t) => { - const schema = z.object({ foo: z.string() }) - const input = {} - t.deepEqual(parseUrlSearchParams(serializeUrlSearchParams(input), schema), { - foo: undefined, - }) +const parses = test.macro({ + title(providedTitle) { + return `parses ${providedTitle}` + }, + exec(t, input: Params, schema: ZodSchema) { + t.deepEqual( + parseUrlSearchParams(serializeUrlSearchParams(input), schema), + input, + ) + }, }) -test('parses nested params', (t) => { - const schema = z.object({ - foo: z.string(), - bar: z.object({ baz: z.string() }), - }) - const input = { foo: 'a', bar: { baz: 'b' } } - t.deepEqual(parseUrlSearchParams(serializeUrlSearchParams(input), schema), { +test( + 'empty params', + parses, + { + foo: undefined, + }, + z.object({ foo: z.string() }), +) + +test( + 'nested params', + parses, + { foo: 'a', bar: { baz: 'b' }, - }) -}) + }, + z.object({ + foo: z.string(), + bar: z.object({ baz: z.string() }), + }), +) From d3c0fafe31b2c5429b3543d42ae406c567b9199c Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 20:35:01 -0700 Subject: [PATCH 21/32] Replace TODO with test.todo --- src/lib/parse.test.ts | 10 ++++++++++ src/lib/parse.ts | 9 --------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index 556d14a..771c7f9 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -23,3 +23,13 @@ test('parseUrlSearchParams: with URLSearchParams input', (t) => { 'with URLSearchParams input', ) }) + +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') + +// 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/src/lib/parse.ts b/src/lib/parse.ts index e2b784b..3e2f27e 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -33,19 +33,10 @@ const parseFromParamSchema = ( path: string[], ): Record | unknown => { if (typeof node === 'string') { - // TODO: For array parsing, try to lookup foo=, then foo[]= patterns, - // if only one match, try to detect commas, otherwise ignore commas. - // if both foo= and foo[]= this is a parse error - // more generally, try to find a matching key for this node in the searchParams - // and throw if conflicting keys are found, e.g, both foo= and foo[]= const key = path.join('.') return parse(key, searchParams.getAll(key), node) } - // TODO: Ensure that there are no conflicting object keys, 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). - const entries = Object.entries(node).reduce< Array<[string, Record | unknown]> >((acc, entry) => { From bd01d41d84f0eb96e7fa31a779c1ae8e711fceb3 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 23:13:18 -0700 Subject: [PATCH 22/32] Add todo tests for generous parsing --- README.md | 17 +++++++++++++++++ src/lib/parse.test.ts | 22 ++++++++++++++++++++++ src/lib/parse.ts | 1 - 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 72e42db..abe0716 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,23 @@ 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 +### 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.boolean()`: + - `1`, `True`, and `TRUE` are all parsed as `true` + - `0`, `False`, and `FALSE` are all parsed as `true` +- 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()`. diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index 771c7f9..26c5046 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -24,11 +24,33 @@ test('parseUrlSearchParams: with URLSearchParams input', (t) => { ) }) +test.todo( + 'parseUrlSearchParams: parse empty or whitespace number params as null', +) +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('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 addtional 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', +) // 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). diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 3e2f27e..a3ca874 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -50,7 +50,6 @@ const parseFromParamSchema = ( const parse = (k: string, values: string[], type: ValueType): unknown => { // TODO: Add better errors with coercion. If coercion fails, passthough? - // TODO: Is this Number parsing safe? if (values.length === 0) return undefined if (type === 'number') return Number(values[0]) if (type === 'boolean') return values[0] === 'true' From c25f13f4cff16602946f1fe8635ff2131f91d5c1 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 23:34:57 -0700 Subject: [PATCH 23/32] Rename test --- test/{parse.test.ts => bijection.test.ts} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename test/{parse.test.ts => bijection.test.ts} (92%) diff --git a/test/parse.test.ts b/test/bijection.test.ts similarity index 92% rename from test/parse.test.ts rename to test/bijection.test.ts index 6eef0ad..d53a8e8 100644 --- a/test/parse.test.ts +++ b/test/bijection.test.ts @@ -7,7 +7,7 @@ import { z, type ZodSchema } from 'zod' import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' -const parses = test.macro({ +const bijection = test.macro({ title(providedTitle) { return `parses ${providedTitle}` }, @@ -21,7 +21,7 @@ const parses = test.macro({ test( 'empty params', - parses, + bijection, { foo: undefined, }, @@ -30,7 +30,7 @@ test( test( 'nested params', - parses, + bijection, { foo: 'a', bar: { baz: 'b' }, From c257cf2727359458dc3e9549eb24d97f80ad7383 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 23:41:29 -0700 Subject: [PATCH 24/32] Test whitespace for number --- src/lib/parse.test.ts | 28 ---------------------- src/lib/parse.ts | 12 +++++++++- test/generous-parsing.test.ts | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 29 deletions(-) create mode 100644 test/generous-parsing.test.ts diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index 26c5046..7b301db 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -24,34 +24,6 @@ test('parseUrlSearchParams: with URLSearchParams input', (t) => { ) }) -test.todo( - 'parseUrlSearchParams: parse empty or whitespace number params as null', -) -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('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 addtional 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', -) - // 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/src/lib/parse.ts b/src/lib/parse.ts index a3ca874..5a12219 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -51,7 +51,12 @@ const parseFromParamSchema = ( const parse = (k: string, values: string[], type: ValueType): unknown => { // TODO: Add better errors with coercion. If coercion fails, passthough? if (values.length === 0) return undefined - if (type === 'number') return Number(values[0]) + + if (values[0] == null) { + throw new Error(`Unexpected nil value when parsing ${k}`) + } + + if (type === 'number') return parseNumber(values[0]) if (type === 'boolean') return values[0] === 'true' if (type === 'string') return String(values[0]) if (type === 'string_array') return values @@ -59,6 +64,11 @@ const parse = (k: string, values: string[], type: ValueType): unknown => { throw new UnparseableSearchParamError(k, 'unsupported type') } +const parseNumber = (v: string): number | null => { + if (v.trim().length === 0) return null + return Number(v) +} + export class UnparseableSearchParamError extends Error { constructor(name: string, message: string) { super(`Could not parse parameter: '${name}' ${message}`) diff --git a/test/generous-parsing.test.ts b/test/generous-parsing.test.ts new file mode 100644 index 0000000..32995c3 --- /dev/null +++ b/test/generous-parsing.test.ts @@ -0,0 +1,45 @@ +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=%20', schema), expected) + t.deepEqual(parseUrlSearchParams('foo= %20 ++ + ', schema), expected) + t.deepEqual(parseUrlSearchParams('foo=+', schema), expected) + }, +}) + +test('number', parseEmptyOrWhitespace, z.number()) + +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('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', +) From a07ff7820925a37bc7b1c37588fa6cbdfeea037f Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 27 Mar 2025 23:58:26 -0700 Subject: [PATCH 25/32] Parse boolean --- README.md | 9 ++++++--- src/lib/parse.test.ts | 5 +++++ src/lib/parse.ts | 23 +++++++++++++++++------ test/generous-parsing.test.ts | 11 ++++++++++- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index abe0716..da488b6 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,12 @@ 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.boolean()`: - - `1`, `True`, and `TRUE` are all parsed as `true` - - `0`, `False`, and `FALSE` are all parsed as `true` +- For `z.number()`, `z.boolean()`, `z.date()`, + starting and ending whitespace is trimmed before parsing. +- For `z.boolean()`, the following values are parsed as `true`: + - +- For `z.boolean()`, the following values are parsed as `false`: + - - Parses `z.array()` in the following formats. In order to support unambiguous parsing, array string values containing a `,` are not supported. diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index 7b301db..2d6e3a1 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -24,6 +24,11 @@ test('parseUrlSearchParams: with URLSearchParams input', (t) => { ) }) +test.todo('pass though number values that parse as NaN') +test.todo( + 'pass though boolean values that do not parse as truthy or falsy values', +) + // 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/src/lib/parse.ts b/src/lib/parse.ts index 5a12219..b2b3e8a 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -49,24 +49,35 @@ const parseFromParamSchema = ( } const parse = (k: string, values: string[], type: ValueType): unknown => { - // TODO: Add better errors with coercion. If coercion fails, passthough? 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]) - if (type === 'boolean') return values[0] === 'true' + 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 if (type === 'number_array') return values.map((v) => Number(v)) throw new UnparseableSearchParamError(k, 'unsupported type') } -const parseNumber = (v: string): number | null => { - if (v.trim().length === 0) return null - return Number(v) +const parseNumber = (v: string): number | null | string => { + if (v.length === 0) return null + const n = Number(v) + if (isNaN(n)) 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 { diff --git a/test/generous-parsing.test.ts b/test/generous-parsing.test.ts index 32995c3..c47897b 100644 --- a/test/generous-parsing.test.ts +++ b/test/generous-parsing.test.ts @@ -11,19 +11,28 @@ const parseEmptyOrWhitespace = test.macro({ 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 ++ + ', 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=', From 6f0bec5aeacddffd988f1fea64b08f17a01dd67d Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Mar 2025 00:19:12 -0700 Subject: [PATCH 26/32] Pass though non-numbers --- src/lib/parse.test.ts | 9 --------- src/lib/parse.ts | 2 ++ test/edge-cases.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 test/edge-cases.test.ts diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index 2d6e3a1..556d14a 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -23,12 +23,3 @@ test('parseUrlSearchParams: with URLSearchParams input', (t) => { 'with URLSearchParams input', ) }) - -test.todo('pass though number values that parse as NaN') -test.todo( - 'pass though boolean values that do not parse as truthy or falsy values', -) - -// 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/src/lib/parse.ts b/src/lib/parse.ts index b2b3e8a..ef9fdab 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -65,8 +65,10 @@ const parse = (k: string, values: string[], type: ValueType): unknown => { 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 } diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts new file mode 100644 index 0000000..1c37505 --- /dev/null +++ b/test/edge-cases.test.ts @@ -0,0 +1,32 @@ +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.todo( + 'pass though boolean values that do not parse as truthy or falsy values', +) + +// 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') From 1edc1f828f7039d2445bf1e7ee68084c2697d8a0 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Mar 2025 00:22:05 -0700 Subject: [PATCH 27/32] Note truthy and falsy values --- README.md | 6 +++--- test/edge-cases.test.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da488b6..cd72f0b 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ However, some additional input cases are handled: 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 values are parsed as `true`: - - +- 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. diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index 1c37505..206e304 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -23,9 +23,15 @@ test('pass though number values that do not parse as number', (t) => { }, ) }) -test.todo( - 'pass though boolean values that do not parse as truthy or falsy values', -) + +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). From 05da702510d640506c13a607c82a922767c76b89 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Fri, 28 Mar 2025 07:22:33 +0000 Subject: [PATCH 28/32] ci: Format code --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cd72f0b..81cdf97 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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. + 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`: @@ -28,9 +28,9 @@ However, some additional input cases are handled: - 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` + - `foo=1&bar=2` + - `foo[]=1&foo[]=2` + - `foo=1,2` ### Allowed Zod Schemas From 9a20fcc0d63c9e325dfb115d753c539bef8cc26e Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Mar 2025 00:26:15 -0700 Subject: [PATCH 29/32] Restrict parsing boolean array --- README.md | 3 +++ src/lib/parse.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81cdf97..1958a11 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ However, some additional input cases are handled: - 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. diff --git a/src/lib/parse.ts b/src/lib/parse.ts index ef9fdab..f2122e1 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -58,8 +58,8 @@ const parse = (k: string, values: string[], type: ValueType): unknown => { 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 - if (type === 'number_array') return values.map((v) => Number(v)) + 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') } From 68f09a3f9ccafe1b9242d686218dbf64dd4d9f8a Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Mar 2025 00:33:53 -0700 Subject: [PATCH 30/32] Add date_array --- src/lib/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 14b2bcb..3246b6c 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -22,6 +22,7 @@ export type ValueType = | 'date' | 'string_array' | 'number_array' + | 'date_array' export interface ParamSchema { [key: string]: ParamSchema | ValueType From f9383367799e0a4b9b898b70c440393b5cfd168d Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Mar 2025 00:36:06 -0700 Subject: [PATCH 31/32] Add record ValueTypes --- src/lib/schema.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 3246b6c..a4533ba 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -23,6 +23,10 @@ export type ValueType = | 'string_array' | 'number_array' | 'date_array' + | 'string_record' + | 'number_record' + | 'boolean_record' + | 'date_record' export interface ParamSchema { [key: string]: ParamSchema | ValueType From c42a12e65235abccd79de5cf4ceb45ab98edfa55 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Mar 2025 08:09:06 -0700 Subject: [PATCH 32/32] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1958a11..9768888 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ However, some additional input cases are handled: - 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'. + `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.