From 084d7d593f9a1856744c1be71b883a5f6a5b0ba5 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 11:03:02 -0800 Subject: [PATCH 1/5] feat: Serialize Date --- src/lib/date.ts | 15 +++++++++++++++ src/lib/serialize.ts | 3 +++ test/serialization.test.ts | 7 +++++++ 3 files changed, 25 insertions(+) create mode 100644 src/lib/date.ts diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..e5c2ea7 --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,15 @@ +interface DateLike { + toISOString: () => string +} + +export const isDateLike = (v: unknown): v is DateLike => { + if (v == null) return false + if (typeof v !== 'object') return false + if ( + v instanceof Date || + Object.prototype.toString.call(v) === '[object Date]' + ) { + return 'toISOString' in v + } + return false +} diff --git a/src/lib/serialize.ts b/src/lib/serialize.ts index 1f1b8dc..7863e26 100644 --- a/src/lib/serialize.ts +++ b/src/lib/serialize.ts @@ -1,3 +1,5 @@ +import { isDateLike } from './date.js' + type Params = Record export const serializeUrlSearchParams = (params: Params): string => { @@ -38,6 +40,7 @@ const serialize = (k: string, v: unknown): string => { if (typeof v === 'number') return v.toString() if (typeof v === 'bigint') return v.toString() if (typeof v === 'boolean') return v.toString() + if (isDateLike(v)) return v.toISOString() throw new UnserializableParamError(k, `is a ${typeof v}`) } diff --git a/test/serialization.test.ts b/test/serialization.test.ts index 0d18a05..01bd46c 100644 --- a/test/serialization.test.ts +++ b/test/serialization.test.ts @@ -75,6 +75,13 @@ test('cannot serialize single element array params with empty string', (t) => { }) }) +test('serializes Date', (t) => { + t.is( + serializeUrlSearchParams({ foo: 1, now: new Date(1740422679000) }), + 'foo=1&now=2025-02-24T18%3A44%3A39.000Z', + ) +}) + test('cannot serialize unserializable values', (t) => { t.throws(() => serializeUrlSearchParams({ foo: {} }), { instanceOf: UnserializableParamError, From cf2943ff7ba442c53656866ad7af72bf9a096f1b Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 11:22:56 -0800 Subject: [PATCH 2/5] feat: Serialize Temporal.Instant --- package-lock.json | 29 +++++++++++++++++++++++++++++ package.json | 1 + src/lib/date.ts | 17 +++++++++++++++++ src/lib/serialize.ts | 5 ++++- test/serialization.test.ts | 12 ++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index baae76f..2f38cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.2", "license": "MIT", "devDependencies": { + "@js-temporal/polyfill": "^0.4.4", "@types/node": "^20.8.10", "ava": "^6.0.1", "axios": "^1.6.5", @@ -810,6 +811,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz", + "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==", + "dev": true, + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -5002,6 +5017,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz", + "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7413,6 +7435,13 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsup": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.0.2.tgz", diff --git a/package.json b/package.json index f44de42..c576b35 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "npm": ">= 9.0.0" }, "devDependencies": { + "@js-temporal/polyfill": "^0.4.4", "@types/node": "^20.8.10", "ava": "^6.0.1", "axios": "^1.6.5", diff --git a/src/lib/date.ts b/src/lib/date.ts index e5c2ea7..9687f6a 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -13,3 +13,20 @@ export const isDateLike = (v: unknown): v is DateLike => { } return false } + +interface TemporalInstantLike { + epochMilliseconds: number +} + +export const isTemporalInstantLike = (v: unknown): v is TemporalInstantLike => { + if (v == null) return false + if (typeof v !== 'object') return false + if (!('epochMilliseconds' in v)) return false + try { + if (typeof v.epochMilliseconds !== 'number') return false + if (isNaN(v.epochMilliseconds)) return false + return true + } catch { + return false + } +} diff --git a/src/lib/serialize.ts b/src/lib/serialize.ts index 7863e26..fc5473a 100644 --- a/src/lib/serialize.ts +++ b/src/lib/serialize.ts @@ -1,4 +1,4 @@ -import { isDateLike } from './date.js' +import { isDateLike, isTemporalInstantLike } from './date.js' type Params = Record @@ -41,6 +41,9 @@ const serialize = (k: string, v: unknown): string => { if (typeof v === 'bigint') return v.toString() if (typeof v === 'boolean') return v.toString() if (isDateLike(v)) return v.toISOString() + if (isTemporalInstantLike(v)) { + return new Date(v.epochMilliseconds).toISOString() + } throw new UnserializableParamError(k, `is a ${typeof v}`) } diff --git a/test/serialization.test.ts b/test/serialization.test.ts index 01bd46c..066d379 100644 --- a/test/serialization.test.ts +++ b/test/serialization.test.ts @@ -1,3 +1,4 @@ +import { Temporal } from '@js-temporal/polyfill' import test from 'ava' import { @@ -82,6 +83,17 @@ test('serializes Date', (t) => { ) }) +test.only('serializes Temporal.Instant', (t) => { + t.is( + serializeUrlSearchParams({ + foo: 1, + now: Temporal.Instant.fromEpochMilliseconds(1740422679000), + }), + + 'foo=1&now=2025-02-24T18%3A44%3A39.000Z', + ) +}) + test('cannot serialize unserializable values', (t) => { t.throws(() => serializeUrlSearchParams({ foo: {} }), { instanceOf: UnserializableParamError, From cec5200ddb3c4ab3f87f2b9c515cdf8fc6b0add7 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 11:53:21 -0800 Subject: [PATCH 3/5] docs: Add serialization strategy --- README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 20620fe..7528384 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,32 @@ See this test for the [serialization behavior](./test/serialization.test.ts). [URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +### Serialization strategy + +Serialization uses +[`URLSearchParams.toString()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/toString#return_value) +which encodes most non-alphanumeric characters. + +- The primitive types of `strings`, `number`, and `bigint` are serialized using `.toString()`. +- The primitive `boolean` type is serialized using `.toString()` to `'true'` or `'false'`. +- The primitive `null` and `undefined` values are removed, + e.g., `{ foo: null, bar: undefined, baz: 1 }` serializes to `baz=1`. +- `Date` objects are detected and serialized using `Date.toISOString()`. +- `Temporal.Instant` objects are detected and serialized by first converting them to `Date` + and then serializing the `Date` as above. +- Arrays are serialized using + [`URLSearchParams.append()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/append): + - Array values are serialized as above. + - The array `{ foo: [1, 2] }` serializes to `foo=1&foo=2`. + - The single element array `{ foo: [1] }` serializes to `foo=1`. + - The empty array `{ foo: [] }` serializes to `foo=`. + - Serialization of the single element array containing the empty string is not supported + and will throw an `UnserializableParamError`. + Otherwise, the serialization of `{ foo: [''] }` would conflict with `{ foo: [] }`. + This serializer chooses to support the more common and more useful case of an empty array. +- Serialization of functions or other objects is not supported + and will throw an `UnserializableParamError`. + ## Motivation URL search parameters are strings, however the Seam API will parse parameters as complex types. @@ -31,7 +57,7 @@ This module establishes the serialization standard adopted by the Seam API. ### Why not [qs](https://github.com/ljharb/qs)? - Not a zero-dependency module. Has quite a few dependency layers. -- Impractile as a reference implementation. +- Impractical as a reference implementation. qs enables complex, non-standard parsing and serialization, which makes ensuing SDK parity much harder. Similarly, this puts an unreasonable burden on user's of the HTTP API or those implementing their own client. From 1ecb026760457378a070261b98bbe94da95fd1ac Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 12:06:18 -0800 Subject: [PATCH 4/5] docs: Move Motivation down in README --- README.md | 58 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7528384..adb7f94 100644 --- a/README.md +++ b/README.md @@ -41,35 +41,6 @@ which encodes most non-alphanumeric characters. - Serialization of functions or other objects is not supported and will throw an `UnserializableParamError`. -## Motivation - -URL search parameters are strings, however the Seam API will parse parameters as complex types. -The Seam SDK must accept the complex types as input and serialize these -to search parameters in a way supported by the Seam API. - -There is no single standard for this serialization. -This module establishes the serialization standard adopted by the Seam API. - -### Why not use [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)? - -- Passing a raw object to URLSearchParams has unpredictable serialization behavior. - -### Why not [qs](https://github.com/ljharb/qs)? - -- Not a zero-dependency module. Has quite a few dependency layers. -- Impractical as a reference implementation. - qs enables complex, non-standard parsing and serialization, - which makes ensuing SDK parity much harder. - Similarly, this puts an unreasonable burden on user's of the HTTP API or those implementing their own client. -- The Seam API must ensure it handles a well defined set of non-string query parameters consistency. - Using qs would allow the SDK to send unsupported or incorrectly serialized parameter types to the API - resulting in unexpected behavior. - -### Why not use the default [Axios](https://axios-http.com/) serializer? - -- Using the default [Axios] serializer was the original approach, - however it had similar issues to using URLSearchParams and qs as noted above. - ## Installation Add this as a dependency to your project using [npm] with @@ -137,6 +108,35 @@ const { data } = await client.get('/search', { [Axios]: https://axios-http.com/ +## Motivation + +URL search parameters are strings, however the Seam API will parse parameters as complex types. +The Seam SDK must accept the complex types as input and serialize these +to search parameters in a way supported by the Seam API. + +There is no single standard for this serialization. +This module establishes the serialization standard adopted by the Seam API. + +### Why not use [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)? + +- Passing a raw object to URLSearchParams has unpredictable serialization behavior. + +### Why not [qs](https://github.com/ljharb/qs)? + +- Not a zero-dependency module. Has quite a few dependency layers. +- Impractical as a reference implementation. + qs enables complex, non-standard parsing and serialization, + which makes ensuing SDK parity much harder. + Similarly, this puts an unreasonable burden on user's of the HTTP API or those implementing their own client. +- The Seam API must ensure it handles a well defined set of non-string query parameters consistency. + Using qs would allow the SDK to send unsupported or incorrectly serialized parameter types to the API + resulting in unexpected behavior. + +### Why not use the default [Axios](https://axios-http.com/) serializer? + +- Using the default [Axios] serializer was the original approach, + however it had similar issues to using URLSearchParams and qs as noted above. + ## Development and Testing ### Quickstart From c0300dc8e856a40b486bf6613d6deacb5aa45003 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 24 Feb 2025 12:11:00 -0800 Subject: [PATCH 5/5] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index adb7f94..550538b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Serialization uses [`URLSearchParams.toString()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/toString#return_value) which encodes most non-alphanumeric characters. -- The primitive types of `strings`, `number`, and `bigint` are serialized using `.toString()`. +- The primitive types `string`, `number`, and `bigint` are serialized using `.toString()`. - The primitive `boolean` type is serialized using `.toString()` to `'true'` or `'false'`. - The primitive `null` and `undefined` values are removed, e.g., `{ foo: null, bar: undefined, baz: 1 }` serializes to `baz=1`.