Skip to content

Commit fad04f2

Browse files
committed
feat: Serialize plain objects to dot path notation
1 parent ca89e53 commit fad04f2

File tree

5 files changed

+89
-13
lines changed

5 files changed

+89
-13
lines changed

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,20 @@ which encodes most non-alphanumeric characters.
3636
- The array `{ foo: [1, 2] }` serializes to `foo=1&foo=2`.
3737
- The single element array `{ foo: [1] }` serializes to `foo=1`.
3838
- The empty array `{ foo: [] }` serializes to `foo=`.
39-
- Serialization of the single element array containing the empty string is not supported
40-
and will throw an `UnserializableParamError`.
39+
- Serialization of arrays containing `null` or `undefined` values
40+
is not supported and will throw an `UnserializableParamError`.
41+
- Serialization of the single element array containing the empty string
42+
is not supported and will throw an `UnserializableParamError`.
4143
Otherwise, the serialization of `{ foo: [''] }` would conflict with `{ foo: [] }`.
4244
This serializer chooses to support the more common and more useful case of an empty array.
43-
- Serialization of functions or other objects is not supported
44-
and will throw an `UnserializableParamError`.
45+
- Serialization of objects and nested objects first serializes the keys
46+
- to dot-path format and then serializes the values as above, e.g.,
47+
`{ foo: 'a', bar: { baz: 'b', fizz: [1, 2] } }` serializes to
48+
`foo=a&bar.baz=b&bar.fizz=1&bar.fizz=2`.
49+
- Serialization of nested arrays or objects nested inside arrays
50+
is not supported and will throw an `UnserializableParamError`.
51+
- Serialization of functions or other objects is
52+
is not supported and will throw an `UnserializableParamError`.
4553

4654
## Installation
4755

examples/axios.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const handler: Handler<Options> = async ({ logger }) => {
2525
f: [],
2626
g: new Date(),
2727
h: Temporal.Now.instant(),
28+
i: { foo: 1, bar: { baz: 2, fizz: [1, 'a'] } },
2829
},
2930
})
3031
logger.info({ data }, 'Response')

src/lib/object.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const isPlainObject = (v: unknown): v is Record<string, unknown> => {
2+
if (v == null) return false
3+
if (typeof v !== 'object') return false
4+
const proto = Object.getPrototypeOf(v)
5+
if (proto === null) return true
6+
if (proto === Object.prototype) return true
7+
return false
8+
}

src/lib/serialize.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isDateLike, isTemporalInstantLike } from './date.js'
2+
import { isPlainObject } from './object.js'
23

34
type Params = Record<string, unknown>
45

@@ -12,7 +13,24 @@ export const updateUrlSearchParams = (
1213
searchParams: URLSearchParams,
1314
params: Record<string, unknown>,
1415
): void => {
15-
for (const [name, value] of Object.entries(params)) {
16+
nestedUpdateUrlSearchParams(searchParams, params, [])
17+
searchParams.sort()
18+
}
19+
20+
const nestedUpdateUrlSearchParams = (
21+
searchParams: URLSearchParams,
22+
params: Record<string, unknown>,
23+
path: string[],
24+
): void => {
25+
for (const [key, value] of Object.entries(params)) {
26+
const currentPath = [...path, key]
27+
if (isPlainObject(value)) {
28+
nestedUpdateUrlSearchParams(searchParams, value, currentPath)
29+
return
30+
}
31+
32+
const name = currentPath.join('.')
33+
1634
if (value == null) continue
1735

1836
if (Array.isArray(value)) {
@@ -31,8 +49,6 @@ export const updateUrlSearchParams = (
3149

3250
searchParams.set(name, serialize(name, value))
3351
}
34-
35-
searchParams.sort()
3652
}
3753

3854
const serialize = (k: string, v: unknown): string => {

test/serialization.test.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,54 @@ test('serializes Temporal.Instant', (t) => {
9494
)
9595
})
9696

97-
test('cannot serialize unserializable values', (t) => {
98-
t.throws(() => serializeUrlSearchParams({ foo: {} }), {
99-
instanceOf: UnserializableParamError,
100-
})
101-
t.throws(() => serializeUrlSearchParams({ foo: { x: 2 } }), {
97+
test('serializes plain objects', (t) => {
98+
t.is(
99+
serializeUrlSearchParams({
100+
foo: 1,
101+
bar: { baz: 'a' },
102+
}),
103+
'bar.baz=a&foo=1',
104+
)
105+
106+
t.is(
107+
serializeUrlSearchParams({
108+
foo: 1,
109+
bar: { baz: { x: { z: 1 } } },
110+
}),
111+
'bar.baz.x.z=1&foo=1',
112+
)
113+
114+
t.is(
115+
serializeUrlSearchParams({
116+
foo: 1,
117+
bar: { baz: { x: { z: null } } },
118+
}),
119+
'foo=1',
120+
)
121+
122+
t.is(
123+
serializeUrlSearchParams({
124+
foo: 1,
125+
bar: { baz: [1, 'a'] },
126+
}),
127+
'bar.baz=1&bar.baz=a&foo=1',
128+
)
129+
})
130+
131+
test('cannot serialize functions', (t) => {
132+
t.throws(() => serializeUrlSearchParams({ foo: () => {} }), {
102133
instanceOf: UnserializableParamError,
103134
})
104-
t.throws(() => serializeUrlSearchParams({ foo: () => {} }), {
135+
})
136+
137+
test('cannot serialize non-plain objects', (t) => {
138+
class Foo {
139+
bar: string
140+
constructor() {
141+
this.bar = 'a'
142+
}
143+
}
144+
t.throws(() => serializeUrlSearchParams({ foo: new Foo() }), {
105145
instanceOf: UnserializableParamError,
106146
})
107147
})
@@ -119,6 +159,9 @@ test('cannot serialize array params with unserializable values', (t) => {
119159
t.throws(() => serializeUrlSearchParams({ bar: ['a', []] }), {
120160
instanceOf: UnserializableParamError,
121161
})
162+
t.throws(() => serializeUrlSearchParams({ bar: ['a', ['']] }), {
163+
instanceOf: UnserializableParamError,
164+
})
122165
t.throws(() => serializeUrlSearchParams({ bar: ['a', {}] }), {
123166
instanceOf: UnserializableParamError,
124167
})

0 commit comments

Comments
 (0)