From 2825d977b25d2368a0e682d6899ca723f21459c0 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 13 Aug 2026 19:53:45 -0700 Subject: [PATCH 1/2] Update fake-seam-connect to v2 --- package-lock.json | 30 ++++++------------------------ package.json | 2 +- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26cdfefb..01b78e9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@seamapi/blueprint": "^1.5.1", - "@seamapi/fake-seam-connect": "^1.77.0", + "@seamapi/fake-seam-connect": "^2.0.4", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", "@types/jsonwebtoken": "^9.0.6", @@ -1039,38 +1039,20 @@ "npm": ">=10.0.0" } }, - "node_modules/@seamapi/fake-devicedb": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@seamapi/fake-devicedb/-/fake-devicedb-1.6.1.tgz", - "integrity": "sha512-w4Ar/s2kPnE5ExJSlpD3sKL8lkF+rLHRROArIRxtR2reHfnDSVwnDt9TzBYkHqgMP/x4o7LUzlRRpobn2xn24A==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18.12.0", - "npm": ">= 9.0.0" - }, - "optionalDependencies": { - "zod": "^3.21.4", - "zustand": "^4.3.7", - "zustand-hoist": "^2.0.0" - } - }, "node_modules/@seamapi/fake-seam-connect": { - "version": "1.86.0", - "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.86.0.tgz", - "integrity": "sha512-iO5fwtSIPhzmIiLxrFDtCYF/7HTb+ywcGmc3WzWt8Sr0bLQlmwCyTJ8YYZy++Hx0FsnNpa5AmlJuJGul9Y5gZA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-2.0.4.tgz", + "integrity": "sha512-pgPUhIMW462B3jIWTuhH/aK2wUxRuOLjhsjB7YxdXw2g+hTVRJ5w4t01spETu1g2bch3Rzffd0whUVLeC4vjwQ==", "dev": true, "license": "MIT", "bin": { "fake-seam-connect": "dist/server.js" }, "engines": { - "node": ">=18.12.0", - "npm": ">= 9.0.0" + "node": ">=22.12.0", + "npm": ">=10.0.0" }, "optionalDependencies": { - "@seamapi/fake-devicedb": ">=1.0.0-rc.0", "zustand": "^4.3.7", "zustand-hoist": "^2.0.0" } diff --git a/package.json b/package.json index fb71ef54..5e5a98a5 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ }, "devDependencies": { "@seamapi/blueprint": "^1.5.1", - "@seamapi/fake-seam-connect": "^1.77.0", + "@seamapi/fake-seam-connect": "^2.0.4", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", "@types/jsonwebtoken": "^9.0.6", From cde8d23ef271d4b3b66e191c320557e898442c4c Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 13 Aug 2026 20:10:25 -0700 Subject: [PATCH 2/2] feat: Add _strict to url serializer --- README.md | 47 ++++++++++++++++ src/index.ts | 1 - src/lib/client.ts | 2 +- src/lib/index.ts | 1 + src/lib/seam-http-request.ts | 2 +- src/lib/url-search-params-serializer.ts | 28 ++++++++++ test/seam/connect/headers.test.ts | 2 +- test/seam/connect/seam-http-request.test.ts | 8 +-- test/seam/connect/serialization.test.ts | 29 ++++------ .../url-search-params-serializer.test.ts | 53 +++++++++++++++++++ 10 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 src/lib/url-search-params-serializer.ts create mode 100644 test/seam/connect/url-search-params-serializer.test.ts diff --git a/README.md b/README.md index f2fc8185..3bcddb92 100644 --- a/README.md +++ b/README.md @@ -526,6 +526,53 @@ console.log(`${request.method} ${request.url}`, JSON.stringify(request.body)) const devices = await request.execute() ``` +#### Serializing URL search params + +The Seam API parses URL search params as complex types. +If you call it with your own HTTP client, use `serializeUrlSearchParams`: + +```ts +import axios from 'axios' +import { serializeUrlSearchParams } from '@seamapi/http' + +await axios.get('https://connect.getseam.com/devices/list', { + params: { device_ids: ['device1', 'device2'] }, + paramsSerializer: serializeUrlSearchParams, + headers: { Authorization: 'Bearer your-api-key' }, +}) +``` + +or `updateUrlSearchParams`: + +```ts +import { updateUrlSearchParams } from '@seamapi/http' + +const searchParams = new URLSearchParams() +updateUrlSearchParams(searchParams, { device_ids: ['device1', 'device2'] }) + +Array.from(searchParams) +// => [['device_ids', 'device1'], ['device_ids', 'device2'], ['_strict', 'true']] + +searchParams.toString() +// => 'device_ids=device1&device_ids=device2&_strict=true' +``` + +The helpers wrap the [reference implementation]. +The serialization defines the name and string value of each search param. +[`URLSearchParams`][URLSearchParams] holds those pairs and renders the query string: +The `_strict=true` parameter is added to any non-empty query so the Seam API uses +strict, schema-aware parsing. +A query with no serializable params remains empty. + +A param set to `undefined` is omitted, while a param set to `null` is serialized +to an empty value, which the Seam API reads as null. +A param that cannot be represented raises an `UnserializableParamError`. +The Seam API parses these params with the corresponding [parser]. + +[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +[reference implementation]: https://github.com/seamapi/url-search-params-serializer +[parser]: https://github.com/seamapi/url-search-params-parser + ## Development and Testing ### Quickstart diff --git a/src/index.ts b/src/index.ts index 9ebc7c8e..4eb60931 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1 @@ export * from './lib/index.js' -export * from '@seamapi/url-search-params-serializer' diff --git a/src/lib/client.ts b/src/lib/client.ts index 8bcb3b26..ad299710 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,8 +1,8 @@ -import { serializeUrlSearchParams } from '@seamapi/url-search-params-serializer' import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' import axiosRetry, { type AxiosRetry, exponentialDelay } from 'axios-retry' import { errorInterceptor } from './error-interceptor.js' +import { serializeUrlSearchParams } from './url-search-params-serializer.js' export type Client = AxiosInstance diff --git a/src/lib/index.ts b/src/lib/index.ts index 210bbaf1..edc7b05a 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -22,3 +22,4 @@ export { isPersonalAccessToken, isPublishableKey, } from './token.js' +export * from './url-search-params-serializer.js' diff --git a/src/lib/seam-http-request.ts b/src/lib/seam-http-request.ts index 9af895c0..c094c73b 100644 --- a/src/lib/seam-http-request.ts +++ b/src/lib/seam-http-request.ts @@ -1,4 +1,3 @@ -import { serializeUrlSearchParams } from '@seamapi/url-search-params-serializer' import type { Method } from 'axios' import type { Client } from './client.js' @@ -9,6 +8,7 @@ import { resolveActionAttempt, } from './resolve-action-attempt.js' import type { ActionAttempt } from './resources/action-attempt.js' +import { serializeUrlSearchParams } from './url-search-params-serializer.js' interface SeamHttpRequestParent { readonly client: Client diff --git a/src/lib/url-search-params-serializer.ts b/src/lib/url-search-params-serializer.ts new file mode 100644 index 00000000..d112aff3 --- /dev/null +++ b/src/lib/url-search-params-serializer.ts @@ -0,0 +1,28 @@ +import { + type Params, + serializeUrlSearchParams as baseSerializeUrlSearchParams, + UnserializableParamError, + updateUrlSearchParams as baseUpdateUrlSearchParams, +} from '@seamapi/url-search-params-serializer' + +export const serializeUrlSearchParams = (params: Params): string => { + const queryString = baseSerializeUrlSearchParams(params) + if (queryString === '') return '' + + const searchParams = new URLSearchParams(queryString) + searchParams.set('_strict', 'true') + return searchParams.toString() +} + +export const updateUrlSearchParams = ( + searchParams: URLSearchParams, + params: Params, +): void => { + baseUpdateUrlSearchParams(searchParams, params) + + if (searchParams.size > 0) { + searchParams.set('_strict', 'true') + } +} + +export { type Params, UnserializableParamError } diff --git a/test/seam/connect/headers.test.ts b/test/seam/connect/headers.test.ts index cf005640..66b1882c 100644 --- a/test/seam/connect/headers.test.ts +++ b/test/seam/connect/headers.test.ts @@ -17,7 +17,7 @@ test('SeamHttp: sends default headers', async (t) => { }, }) .get('/devices/get') - .query({ device_id: deviceId }) + .query({ _strict: 'true', device_id: deviceId }) .reply(200, { device: { device_id: deviceId } }) const seam = new SeamHttp({ apiKey: seed.seam_apikey1_token, endpoint }) const device = await seam.devices.get({ diff --git a/test/seam/connect/seam-http-request.test.ts b/test/seam/connect/seam-http-request.test.ts index b5b72506..0d9d9e96 100644 --- a/test/seam/connect/seam-http-request.test.ts +++ b/test/seam/connect/seam-http-request.test.ts @@ -65,7 +65,7 @@ test.failing( toPlainUrlObject(url), toPlainUrlObject( new URL( - `${endpoint}/devices/get?device_ids=${seed.august_device_1}&device_ids=${seed.ecobee_device_1}&limit=10`, + `${endpoint}/devices/get?device_ids=${seed.august_device_1}&device_ids=${seed.ecobee_device_1}&limit=10&_strict=true`, ), ), ) @@ -84,7 +84,7 @@ test('SeamHttpRequest: url is a URL when endpoint is a url without a path', asyn t.deepEqual( toPlainUrlObject(url), toPlainUrlObject( - new URL('https://example.com/devices/get?device_id=abc123'), + new URL('https://example.com/devices/get?device_id=abc123&_strict=true'), ), ) }) @@ -101,7 +101,9 @@ test('SeamHttpRequest: url is a URL when endpoint is a url with a path', async ( t.deepEqual( toPlainUrlObject(url), toPlainUrlObject( - new URL('https://example.com/some/sub/path/devices/get?device_id=abc123'), + new URL( + 'https://example.com/some/sub/path/devices/get?device_id=abc123&_strict=true', + ), ), ) }) diff --git a/test/seam/connect/serialization.test.ts b/test/seam/connect/serialization.test.ts index 5209ebaa..39a9535d 100644 --- a/test/seam/connect/serialization.test.ts +++ b/test/seam/connect/serialization.test.ts @@ -48,24 +48,17 @@ test('serializes array params when undefined and explicitly using get', async (t t.is(devices.length, db.devices.length) }) -// UPSTREAM: nextlove parses device_ids= to [''] but should parse this to [] -test.failing( - 'serializes array params when empty and explicitly using get', - async (t) => { - const { seed, endpoint } = await getTestServer(t) - const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) - const { data } = await seam.client.get( - '/devices/list', - { - params: { - device_ids: [], - }, - }, - ) - const devices = data?.devices - t.is(devices.length, 0) - }, -) +test('serializes array params when empty and explicitly using get', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + const { data } = await seam.client.get('/devices/list', { + params: { + device_ids: [], + }, + }) + const devices = data?.devices + t.is(devices.length, 0) +}) test('serializes array params when non-empty and explicitly using get', async (t) => { const { seed, endpoint } = await getTestServer(t) diff --git a/test/seam/connect/url-search-params-serializer.test.ts b/test/seam/connect/url-search-params-serializer.test.ts new file mode 100644 index 00000000..5dc6fde6 --- /dev/null +++ b/test/seam/connect/url-search-params-serializer.test.ts @@ -0,0 +1,53 @@ +import test from 'ava' + +import { + serializeUrlSearchParams, + updateUrlSearchParams, +} from '@seamapi/http/connect' + +test('serializeUrlSearchParams adds strict mode to a non-empty query', (t) => { + t.is( + serializeUrlSearchParams({ + device_ids: ['device1', 'device2'], + }), + 'device_ids=device1&device_ids=device2&_strict=true', + ) +}) + +test('serializeUrlSearchParams leaves an empty query empty', (t) => { + t.is(serializeUrlSearchParams({}), '') + t.is(serializeUrlSearchParams({ device_ids: undefined }), '') +}) + +test('serializeUrlSearchParams overrides the strict param', (t) => { + t.is(serializeUrlSearchParams({ _strict: false }), '_strict=true') +}) + +test('updateUrlSearchParams adds strict mode to non-empty params', (t) => { + const searchParams = new URLSearchParams() + + updateUrlSearchParams(searchParams, { + device_ids: ['device1', 'device2'], + }) + + t.is( + searchParams.toString(), + 'device_ids=device1&device_ids=device2&_strict=true', + ) +}) + +test('updateUrlSearchParams leaves empty params empty', (t) => { + const searchParams = new URLSearchParams() + + updateUrlSearchParams(searchParams, {}) + + t.is(searchParams.toString(), '') +}) + +test('updateUrlSearchParams adds strict mode to existing params', (t) => { + const searchParams = new URLSearchParams({ existing: 'value' }) + + updateUrlSearchParams(searchParams, {}) + + t.is(searchParams.toString(), 'existing=value&_strict=true') +})