Skip to content

Commit b00feaf

Browse files
authored
Merge pull request #15 from seamapi/beta
Version 2
2 parents 0c0dcdb + f777c43 commit b00feaf

File tree

5 files changed

+143
-39
lines changed

5 files changed

+143
-39
lines changed

README.md

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,29 @@ Defines the standard for how the Seam SDKs and other Seam API consumers
1111
should serialize objects to [URLSearchParams] in HTTP GET requests.
1212
Serves as a reference implementation for Seam SDKs in other languages.
1313

14-
See this test for the [serialization behavior](./test/serialization.test.ts).
14+
This serializer may be used as a true inverse operation to [@seamapi/url-search-params-parser][@url-search-params-parser].
1515

1616
[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
17+
[@url-search-params-parser]: https://github.com/seamapi/url-search-params-parser
1718

1819
### Serialization strategy
1920

2021
Serialization uses
2122
[`URLSearchParams.toString()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/toString#return_value)
2223
which encodes most non-alphanumeric characters.
2324

24-
Serialization is guaranteed to be well-defined within each type, i.e.,
25-
if the type of value for a given key in the query string is fixed and known by
26-
the consumer parsing the string, it can be unambigously parsed back to the original primitive value.
27-
25+
- The primitive `null` is serialized to an empty value,
26+
e.g., `{ foo: null }` serializes to `foo=`.
27+
- Any `undefined` values are removed,
28+
e.g., `{ foo: undefined, bar: 1 }` serializes to `bar=1`.
2829
- The primitive type `string` is serialized using `.toString()`.
30+
- Serialization of the empty string
31+
is not supported and will throw an `UnserializableParamError`.
32+
Otherwise, the serialization of `{ foo: null }` would conflict with `{ foo: '' }`.
33+
This serializer chooses to support the more common and more useful case of `null`.
2934
- The primitive `number` and `bigint` types are serialized using `.toString()`.
3035
- The primitive `boolean` type is serialized using `.toString()`,
3136
e.g., `{ foo: true, bar: false }` serializes to `foo=true&bar=false`.
32-
- The primitive `null` and `undefined` values are removed,
33-
e.g., `{ foo: null, bar: undefined, baz: 1 }` serializes to `baz=1`.
3437
- `Date` objects are detected and serialized using `Date.toISOString()`,
3538
e.g., `{ foo: new Date(0) }` serializes to `foo=1970-01-01T00%3A00%3A00.000Z`.
3639
- `Temporal.Instant` objects are detected and serialized by first converting them to `Date`
@@ -41,20 +44,60 @@ the consumer parsing the string, it can be unambigously parsed back to the origi
4144
- The array `{ foo: [1, 2] }` serializes to `foo=1&foo=2`.
4245
- The single element array `{ foo: [1] }` serializes to `foo=1`.
4346
- The empty array `{ foo: [] }` serializes to `foo=`.
47+
As this serialization overlaps with `null`, parser implementations are advised
48+
not to support nullable array parameters and parse this as the empty array.
49+
- To support typed tuples, serialization of arrays containing mixed values is allowed.
4450
- Serialization of arrays containing `null` or `undefined` values
4551
is not supported and will throw an `UnserializableParamError`.
46-
- Serialization of the single element array containing the empty string
52+
- Serialization of arrays containing the empty string
4753
is not supported and will throw an `UnserializableParamError`.
4854
Otherwise, the serialization of `{ foo: [''] }` would conflict with `{ foo: [] }`.
4955
This serializer chooses to support the more common and more useful case of an empty array.
5056
- Serialization of objects and nested objects first serializes the keys
5157
to dot-path format and then serializes the values as above, e.g.,
5258
`{ foo: 'a', bar: { baz: 'b', fizz: [1, 2] } }` serializes to
5359
`foo=a&bar.baz=b&bar.fizz=1&bar.fizz=2`.
60+
- Serialization of keys containing a `.`
61+
is not supported and will throw an `UnserializableParamError`.
5462
- Serialization of nested arrays or objects nested inside arrays
5563
is not supported and will throw an `UnserializableParamError`.
5664
- Serialization of functions or other objects is
5765
is not supported and will throw an `UnserializableParamError`.
66+
- Serialization of `NaN`, `Infinity`, and `-Infinity`
67+
is not supported and will throw an `UnserializableParamError`.
68+
69+
### Compatible parsing strategy
70+
71+
Serialization is guaranteed to be well-defined within each type, i.e.,
72+
if the value-type for a given key in the query string is fixed and known by
73+
the consumer parsing the string, it can be unambigously parsed back to the original primitive value:
74+
75+
- A parser can implement a inverse function to the serialization if it uses a schema which,
76+
for each node, defines a type matching exactly one of the primitive or nested types below.
77+
- Any node may be marked optional, i.e., `undefined`, which corresponds to the absence of the key in the query string.
78+
- The parser may choose `Temporal.Instant` as an equivalent alternative to `Date`.
79+
- Object keys must not include a `.`.
80+
81+
##### Primitive
82+
83+
- `string | null`.
84+
- Excludes zero-length strings.
85+
- `number | null`.
86+
- `bigint | null`.
87+
- `boolean | null`.
88+
- `Date | null`.
89+
- `Array<string | number | bigint | boolean | Date>`.
90+
- Excludes zero-length strings.
91+
- Arrays must use a single value-type.
92+
- Otherwise, the array may be a tuple which may use a different value-type per slot.
93+
94+
#### Nested
95+
96+
- `object | null`.
97+
- Must meet the same constraints as the top level schema.
98+
- `Record | null`.
99+
- The record type must use a `string` type for the key,
100+
and a single primitive or single nested type for the value.
58101

59102
## Installation
60103

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@seamapi/url-search-params-serializer",
3-
"version": "1.3.0",
3+
"version": "2.0.0-beta.1",
44
"description": "Serializes JavaScript objects to URLSearchParams.",
55
"type": "module",
66
"main": "index.js",

src/lib/serialize.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { isDateLike, isTemporalInstantLike } from './date.js'
22
import { isPlainObject } from './object.js'
33

4-
type Params = Record<string, unknown>
4+
export type Params = Record<string, unknown>
55

66
export const serializeUrlSearchParams = (params: Params): string => {
77
const searchParams = new URLSearchParams()
@@ -23,6 +23,13 @@ const nestedUpdateUrlSearchParams = (
2323
path: string[],
2424
): void => {
2525
for (const [key, value] of Object.entries(params)) {
26+
if (key.includes('.')) {
27+
throw new UnserializableParamError(
28+
key,
29+
'contains one or more dots "." in its name which is unsupported',
30+
)
31+
}
32+
2633
const currentPath = [...path, key]
2734
if (isPlainObject(value)) {
2835
nestedUpdateUrlSearchParams(searchParams, value, currentPath)
@@ -31,14 +38,32 @@ const nestedUpdateUrlSearchParams = (
3138

3239
const name = currentPath.join('.')
3340

34-
if (value == null) continue
41+
if (value == null && value !== null) {
42+
continue
43+
}
3544

3645
if (Array.isArray(value)) {
37-
if (value.length === 0) searchParams.set(name, '')
46+
if (value.length === 0) {
47+
searchParams.set(name, '')
48+
continue
49+
}
50+
3851
if (value.length === 1 && value[0] === '') {
3952
throw new UnserializableParamError(
4053
name,
41-
`is a single element array containing the empty string which is unsupported because it serializes to the empty array`,
54+
'is a single element array containing the empty string which is unsupported',
55+
)
56+
}
57+
if (value.some((v) => v === '')) {
58+
throw new UnserializableParamError(
59+
name,
60+
'is an array containing the empty string which is unsupported',
61+
)
62+
}
63+
if (value.some((v) => v == null)) {
64+
throw new UnserializableParamError(
65+
name,
66+
'is an array containing null or undefined values which is unsupported',
4267
)
4368
}
4469
for (const v of value) {
@@ -52,8 +77,29 @@ const nestedUpdateUrlSearchParams = (
5277
}
5378

5479
const serialize = (k: string, v: unknown): string => {
55-
if (typeof v === 'string') return v.toString()
56-
if (typeof v === 'number') return v.toString()
80+
if (v === null) return ''
81+
if (typeof v === 'string') {
82+
if (v.length === 0) {
83+
throw new UnserializableParamError(
84+
k,
85+
'is the empty string which is unsupported',
86+
)
87+
}
88+
return v.toString()
89+
}
90+
if (typeof v === 'number') {
91+
if (
92+
isNaN(v) ||
93+
v === Infinity ||
94+
v === -Infinity ||
95+
v.toString() === 'NaN' ||
96+
v.toString() === 'Infinity' ||
97+
v.toString() === '-Infinity'
98+
) {
99+
throw new UnserializableParamError(k, `is ${v}`)
100+
}
101+
return v.toString()
102+
}
57103
if (typeof v === 'bigint') return v.toString()
58104
if (typeof v === 'boolean') return v.toString()
59105
if (isDateLike(v)) return v.toISOString()

test/serialization.test.ts

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ test('removes undefined params', (t) => {
3232
t.is(serializeUrlSearchParams({ foo: 1, bar: undefined }), 'foo=1')
3333
})
3434

35-
test('removes null params', (t) => {
36-
t.is(serializeUrlSearchParams({ bar: null }), '')
37-
t.is(serializeUrlSearchParams({ foo: 1, bar: null }), 'foo=1')
35+
test('serializes null params', (t) => {
36+
t.is(serializeUrlSearchParams({ bar: null }), 'bar=')
37+
t.is(serializeUrlSearchParams({ foo: 1, bar: null }), 'bar=&foo=1')
3838
})
3939

4040
test('serializes empty array params', (t) => {
@@ -56,24 +56,6 @@ test('serializes array params with many values', (t) => {
5656
serializeUrlSearchParams({ foo: 1, bar: ['null', '2', 'undefined'] }),
5757
'bar=null&bar=2&bar=undefined&foo=1',
5858
)
59-
t.is(
60-
serializeUrlSearchParams({ foo: 1, bar: ['', '', ''] }),
61-
'bar=&bar=&bar=&foo=1',
62-
)
63-
t.is(
64-
serializeUrlSearchParams({ foo: 1, bar: ['', 'a', '2'] }),
65-
'bar=&bar=a&bar=2&foo=1',
66-
)
67-
t.is(
68-
serializeUrlSearchParams({ foo: 1, bar: ['', 'a', ''] }),
69-
'bar=&bar=a&bar=&foo=1',
70-
)
71-
})
72-
73-
test('cannot serialize single element array params with empty string', (t) => {
74-
t.throws(() => serializeUrlSearchParams({ foo: [''] }), {
75-
instanceOf: UnserializableParamError,
76-
})
7759
})
7860

7961
test('serializes Date', (t) => {
@@ -116,7 +98,7 @@ test('serializes plain objects', (t) => {
11698
foo: 1,
11799
bar: { baz: { x: { z: null } } },
118100
}),
119-
'foo=1',
101+
'bar.baz.x.z=&foo=1',
120102
)
121103

122104
t.is(
@@ -128,12 +110,33 @@ test('serializes plain objects', (t) => {
128110
)
129111
})
130112

113+
test('cannot serialize keys containing a .', (t) => {
114+
t.throws(() => serializeUrlSearchParams({ 'foo.bar': 1 }), {
115+
instanceOf: UnserializableParamError,
116+
})
117+
t.throws(() => serializeUrlSearchParams({ foo: { 'bar.baz': 1 } }), {
118+
instanceOf: UnserializableParamError,
119+
})
120+
})
121+
131122
test('cannot serialize functions', (t) => {
132123
t.throws(() => serializeUrlSearchParams({ foo: () => {} }), {
133124
instanceOf: UnserializableParamError,
134125
})
135126
})
136127

128+
test('cannot serialize number pointers', (t) => {
129+
t.throws(() => serializeUrlSearchParams({ foo: Infinity }), {
130+
instanceOf: UnserializableParamError,
131+
})
132+
t.throws(() => serializeUrlSearchParams({ foo: -Infinity }), {
133+
instanceOf: UnserializableParamError,
134+
})
135+
t.throws(() => serializeUrlSearchParams({ foo: -NaN }), {
136+
instanceOf: UnserializableParamError,
137+
})
138+
})
139+
137140
test('cannot serialize non-plain objects', (t) => {
138141
class Foo {
139142
bar: string
@@ -147,6 +150,9 @@ test('cannot serialize non-plain objects', (t) => {
147150
})
148151

149152
test('cannot serialize array params with unserializable values', (t) => {
153+
t.throws(() => serializeUrlSearchParams({ foo: [''] }), {
154+
instanceOf: UnserializableParamError,
155+
})
150156
t.throws(() => serializeUrlSearchParams({ bar: ['a', null] }), {
151157
instanceOf: UnserializableParamError,
152158
})
@@ -171,4 +177,13 @@ test('cannot serialize array params with unserializable values', (t) => {
171177
t.throws(() => serializeUrlSearchParams({ bar: ['a', () => {}] }), {
172178
instanceOf: UnserializableParamError,
173179
})
180+
t.throws(() => serializeUrlSearchParams({ foo: 1, bar: ['', 'a', ''] }), {
181+
instanceOf: UnserializableParamError,
182+
})
183+
t.throws(() => serializeUrlSearchParams({ foo: 1, bar: ['', 'a', '2'] }), {
184+
instanceOf: UnserializableParamError,
185+
})
186+
t.throws(() => serializeUrlSearchParams({ foo: 1, bar: ['', '', ''] }), {
187+
instanceOf: UnserializableParamError,
188+
})
174189
})

0 commit comments

Comments
 (0)