Skip to content

Add parseUrlSearchParams #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 32 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f73d27e
Add @seamapi/url-search-params-serializer for testing
razor-x Feb 25, 2025
6261ad0
Add zod for testing
razor-x Feb 25, 2025
2ad9d57
Add parseUrlSearchParams with failing tests
razor-x Feb 25, 2025
11e98ca
ci: Generate code
seambot Feb 25, 2025
3308bc4
Add basic parsing implementation
razor-x Feb 25, 2025
5dfd48e
Add basic coercion
razor-x Feb 25, 2025
af489b8
Add suggested strategy with zodSchemaToParamSchema
razor-x Feb 26, 2025
4e39fd8
Add UnparseableSchemaError
razor-x Mar 1, 2025
d990492
Implement basic zodSchemaToParamSchema
razor-x Mar 1, 2025
2295f48
Use zodSchemaToParamSchema in parseUrlSearchParams
razor-x Mar 1, 2025
ebecd70
Test nesting
razor-x Mar 1, 2025
be0f990
Add parsing rules
razor-x Mar 4, 2025
9533aa4
ci: Format code
seambot Mar 4, 2025
45d311a
Add TODO
razor-x Mar 4, 2025
753923d
Update to url-search-params-serializer v2
razor-x Mar 28, 2025
40ffb39
Extend README
razor-x Mar 28, 2025
0783808
Add usage
razor-x Mar 28, 2025
96a4f8a
ci: Format code
seambot Mar 28, 2025
5145615
Update README.md
razor-x Mar 28, 2025
24b9cb5
Use test macro
razor-x Mar 28, 2025
d3c0faf
Replace TODO with test.todo
razor-x Mar 28, 2025
bd01d41
Add todo tests for generous parsing
razor-x Mar 28, 2025
c25f13f
Rename test
razor-x Mar 28, 2025
c257cf2
Test whitespace for number
razor-x Mar 28, 2025
a07ff78
Parse boolean
razor-x Mar 28, 2025
6f0bec5
Pass though non-numbers
razor-x Mar 28, 2025
1edc1f8
Note truthy and falsy values
razor-x Mar 28, 2025
05da702
ci: Format code
seambot Mar 28, 2025
9a20fcc
Restrict parsing boolean array
razor-x Mar 28, 2025
68f09a3
Add date_array
razor-x Mar 28, 2025
f938336
Add record ValueTypes
razor-x Mar 28, 2025
c42a12e
Update README.md
razor-x Mar 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phpnode Let me know if these rules make sense 🙏🏻


## Installation

Expand Down
4 changes: 2 additions & 2 deletions examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
47 changes: 47 additions & 0 deletions examples/parse.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = 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()
23 changes: 0 additions & 23 deletions examples/todo.ts

This file was deleted.

28 changes: 27 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@
"node": ">=18.12.0",
"npm": ">= 9.0.0"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"devDependencies": {
"@seamapi/url-search-params-serializer": "^1.3.0",
"@types/node": "^20.8.10",
"ava": "^6.0.1",
"c8": "^10.1.2",
Expand All @@ -83,6 +87,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"
}
}
2 changes: 1 addition & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { todo } from './todo.js'
export * from './parse.js'
25 changes: 25 additions & 0 deletions src/lib/parse.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
73 changes: 73 additions & 0 deletions src/lib/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ZodTypeAny } from 'zod'

import {
type ParamSchema,
type ValueType,
zodSchemaToParamSchema,
} from './schema.js'
import { isZodObject } from './zod.js'

export const parseUrlSearchParams = (
query: URLSearchParams | string,
schema: ZodTypeAny,
): Record<string, unknown> => {
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<string, unknown> | 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)
}

const entries = Object.entries(node).reduce<
Array<[string, Record<string, unknown> | 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 => {
// 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'
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')
}

export class UnparseableSearchParamError extends Error {
constructor(name: string, message: string) {
super(`Could not parse parameter: '${name}' ${message}`)
this.name = this.constructor.name
}
}
80 changes: 80 additions & 0 deletions src/lib/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
Loading