Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 6 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from './lib/index.js'
export * from '@seamapi/url-search-params-serializer'
2 changes: 1 addition & 1 deletion src/lib/client.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export {
isPersonalAccessToken,
isPublishableKey,
} from './token.js'
export * from './url-search-params-serializer.js'
2 changes: 1 addition & 1 deletion src/lib/seam-http-request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { serializeUrlSearchParams } from '@seamapi/url-search-params-serializer'
import type { Method } from 'axios'

import type { Client } from './client.js'
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/lib/url-search-params-serializer.ts
Original file line number Diff line number Diff line change
@@ -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 }
2 changes: 1 addition & 1 deletion test/seam/connect/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
8 changes: 5 additions & 3 deletions test/seam/connect/seam-http-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
),
),
)
Expand All @@ -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'),
),
)
})
Expand All @@ -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',
),
),
)
})
Expand Down
29 changes: 11 additions & 18 deletions test/seam/connect/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DevicesListResponse>(
'/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<DevicesListResponse>('/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)
Expand Down
53 changes: 53 additions & 0 deletions test/seam/connect/url-search-params-serializer.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
Loading