Skip to content

Commit 619b45f

Browse files
authored
Support Date and Temporal.Instant (#6)
* feat: Serialize Date * feat: Serialize Temporal.Instant * docs: Add serialization strategy * docs: Move Motivation down in README * Fix typo
2 parents b6cdb26 + c0300dc commit 619b45f

File tree

6 files changed

+141
-28
lines changed

6 files changed

+141
-28
lines changed

README.md

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,34 +15,31 @@ See this test for the [serialization behavior](./test/serialization.test.ts).
1515

1616
[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
1717

18-
## Motivation
19-
20-
URL search parameters are strings, however the Seam API will parse parameters as complex types.
21-
The Seam SDK must accept the complex types as input and serialize these
22-
to search parameters in a way supported by the Seam API.
23-
24-
There is no single standard for this serialization.
25-
This module establishes the serialization standard adopted by the Seam API.
26-
27-
### Why not use [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)?
28-
29-
- Passing a raw object to URLSearchParams has unpredictable serialization behavior.
30-
31-
### Why not [qs](https://github.com/ljharb/qs)?
32-
33-
- Not a zero-dependency module. Has quite a few dependency layers.
34-
- Impractile as a reference implementation.
35-
qs enables complex, non-standard parsing and serialization,
36-
which makes ensuing SDK parity much harder.
37-
Similarly, this puts an unreasonable burden on user's of the HTTP API or those implementing their own client.
38-
- The Seam API must ensure it handles a well defined set of non-string query parameters consistency.
39-
Using qs would allow the SDK to send unsupported or incorrectly serialized parameter types to the API
40-
resulting in unexpected behavior.
41-
42-
### Why not use the default [Axios](https://axios-http.com/) serializer?
43-
44-
- Using the default [Axios] serializer was the original approach,
45-
however it had similar issues to using URLSearchParams and qs as noted above.
18+
### Serialization strategy
19+
20+
Serialization uses
21+
[`URLSearchParams.toString()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/toString#return_value)
22+
which encodes most non-alphanumeric characters.
23+
24+
- The primitive types `string`, `number`, and `bigint` are serialized using `.toString()`.
25+
- The primitive `boolean` type is serialized using `.toString()` to `'true'` or `'false'`.
26+
- The primitive `null` and `undefined` values are removed,
27+
e.g., `{ foo: null, bar: undefined, baz: 1 }` serializes to `baz=1`.
28+
- `Date` objects are detected and serialized using `Date.toISOString()`.
29+
- `Temporal.Instant` objects are detected and serialized by first converting them to `Date`
30+
and then serializing the `Date` as above.
31+
- Arrays are serialized using
32+
[`URLSearchParams.append()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/append):
33+
- Array values are serialized as above.
34+
- The array `{ foo: [1, 2] }` serializes to `foo=1&foo=2`.
35+
- The single element array `{ foo: [1] }` serializes to `foo=1`.
36+
- The empty array `{ foo: [] }` serializes to `foo=`.
37+
- Serialization of the single element array containing the empty string is not supported
38+
and will throw an `UnserializableParamError`.
39+
Otherwise, the serialization of `{ foo: [''] }` would conflict with `{ foo: [] }`.
40+
This serializer chooses to support the more common and more useful case of an empty array.
41+
- Serialization of functions or other objects is not supported
42+
and will throw an `UnserializableParamError`.
4643

4744
## Installation
4845

@@ -111,6 +108,35 @@ const { data } = await client.get('/search', {
111108

112109
[Axios]: https://axios-http.com/
113110

111+
## Motivation
112+
113+
URL search parameters are strings, however the Seam API will parse parameters as complex types.
114+
The Seam SDK must accept the complex types as input and serialize these
115+
to search parameters in a way supported by the Seam API.
116+
117+
There is no single standard for this serialization.
118+
This module establishes the serialization standard adopted by the Seam API.
119+
120+
### Why not use [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)?
121+
122+
- Passing a raw object to URLSearchParams has unpredictable serialization behavior.
123+
124+
### Why not [qs](https://github.com/ljharb/qs)?
125+
126+
- Not a zero-dependency module. Has quite a few dependency layers.
127+
- Impractical as a reference implementation.
128+
qs enables complex, non-standard parsing and serialization,
129+
which makes ensuing SDK parity much harder.
130+
Similarly, this puts an unreasonable burden on user's of the HTTP API or those implementing their own client.
131+
- The Seam API must ensure it handles a well defined set of non-string query parameters consistency.
132+
Using qs would allow the SDK to send unsupported or incorrectly serialized parameter types to the API
133+
resulting in unexpected behavior.
134+
135+
### Why not use the default [Axios](https://axios-http.com/) serializer?
136+
137+
- Using the default [Axios] serializer was the original approach,
138+
however it had similar issues to using URLSearchParams and qs as noted above.
139+
114140
## Development and Testing
115141

116142
### Quickstart

package-lock.json

Lines changed: 29 additions & 0 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"npm": ">= 9.0.0"
6969
},
7070
"devDependencies": {
71+
"@js-temporal/polyfill": "^0.4.4",
7172
"@types/node": "^20.8.10",
7273
"ava": "^6.0.1",
7374
"axios": "^1.6.5",

src/lib/date.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
interface DateLike {
2+
toISOString: () => string
3+
}
4+
5+
export const isDateLike = (v: unknown): v is DateLike => {
6+
if (v == null) return false
7+
if (typeof v !== 'object') return false
8+
if (
9+
v instanceof Date ||
10+
Object.prototype.toString.call(v) === '[object Date]'
11+
) {
12+
return 'toISOString' in v
13+
}
14+
return false
15+
}
16+
17+
interface TemporalInstantLike {
18+
epochMilliseconds: number
19+
}
20+
21+
export const isTemporalInstantLike = (v: unknown): v is TemporalInstantLike => {
22+
if (v == null) return false
23+
if (typeof v !== 'object') return false
24+
if (!('epochMilliseconds' in v)) return false
25+
try {
26+
if (typeof v.epochMilliseconds !== 'number') return false
27+
if (isNaN(v.epochMilliseconds)) return false
28+
return true
29+
} catch {
30+
return false
31+
}
32+
}

src/lib/serialize.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isDateLike, isTemporalInstantLike } from './date.js'
2+
13
type Params = Record<string, unknown>
24

35
export const serializeUrlSearchParams = (params: Params): string => {
@@ -38,6 +40,10 @@ const serialize = (k: string, v: unknown): string => {
3840
if (typeof v === 'number') return v.toString()
3941
if (typeof v === 'bigint') return v.toString()
4042
if (typeof v === 'boolean') return v.toString()
43+
if (isDateLike(v)) return v.toISOString()
44+
if (isTemporalInstantLike(v)) {
45+
return new Date(v.epochMilliseconds).toISOString()
46+
}
4147
throw new UnserializableParamError(k, `is a ${typeof v}`)
4248
}
4349

test/serialization.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Temporal } from '@js-temporal/polyfill'
12
import test from 'ava'
23

34
import {
@@ -75,6 +76,24 @@ test('cannot serialize single element array params with empty string', (t) => {
7576
})
7677
})
7778

79+
test('serializes Date', (t) => {
80+
t.is(
81+
serializeUrlSearchParams({ foo: 1, now: new Date(1740422679000) }),
82+
'foo=1&now=2025-02-24T18%3A44%3A39.000Z',
83+
)
84+
})
85+
86+
test.only('serializes Temporal.Instant', (t) => {
87+
t.is(
88+
serializeUrlSearchParams({
89+
foo: 1,
90+
now: Temporal.Instant.fromEpochMilliseconds(1740422679000),
91+
}),
92+
93+
'foo=1&now=2025-02-24T18%3A44%3A39.000Z',
94+
)
95+
})
96+
7897
test('cannot serialize unserializable values', (t) => {
7998
t.throws(() => serializeUrlSearchParams({ foo: {} }), {
8099
instanceOf: UnserializableParamError,

0 commit comments

Comments
 (0)