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 all 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
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
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.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
".": {
Expand Down Expand Up @@ -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",
Expand All @@ -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"
}
}
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',
)
})
90 changes: 90 additions & 0 deletions src/lib/parse.ts
Original file line number Diff line number Diff line change
@@ -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<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') {
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 => {
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
}
}
Loading