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
35 changes: 35 additions & 0 deletions src/lib/seam-http-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,38 @@ export const isSeamHttpInvalidInputError = (
): error is SeamHttpInvalidInputError => {
return error instanceof SeamHttpInvalidInputError
}

/**
* Error thrown when the Seam API returns a success response
* with an unexpected shape,
* e.g., a response missing the expected response key.
*/
export class SeamHttpInvalidResponseError extends Error {
/**
* Path of the endpoint that returned the invalid response.
*/
path: string

/**
* Key expected to contain the response data.
*/
responseKey: string

constructor(path: string, responseKey: string, reason: string) {
super(
`Seam returned an invalid response for ${path}: expected "${responseKey}", ${reason}`,
)
this.name = this.constructor.name
this.path = path
this.responseKey = responseKey
}
}

/**
* Returns true if the error is a {@link SeamHttpInvalidResponseError}.
*/
export const isSeamHttpInvalidResponseError = (
error: unknown,
): error is SeamHttpInvalidResponseError => {
return error instanceof SeamHttpInvalidResponseError
}
39 changes: 38 additions & 1 deletion src/lib/seam-http-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveActionAttempt,
} from './resolve-action-attempt.js'
import type { ActionAttempt } from './resources/action-attempt.js'
import { SeamHttpInvalidResponseError } from './seam-http-error.js'
import { serializeUrlSearchParams } from './url-search-params-serializer.js'

interface SeamHttpRequestParent {
Expand Down Expand Up @@ -132,7 +133,11 @@ export class SeamHttpRequest<
return undefined as Response
}

const data = response[this.responseKey] as unknown as Response
const data = readResponseData(
response,
this.responseKey,
this.pathname,
) as Response

if (this.responseKey === 'action_attempt') {
const waitForActionAttempt =
Expand Down Expand Up @@ -222,6 +227,38 @@ export class SeamHttpRequest<
}
}

/**
* Reads the response data at the response key,
* throwing a {@link SeamHttpInvalidResponseError} for a success response
* that is not an object or does not contain the response key.
*/
export const readResponseData = <
TResponse,
TResponseKey extends keyof TResponse,
>(
response: TResponse,
responseKey: TResponseKey,
path: string,
): TResponse[TResponseKey] => {
if (response == null || typeof response !== 'object') {
throw new SeamHttpInvalidResponseError(
path,
String(responseKey),
`got ${response === null ? 'null' : typeof response} instead of a response object`,
)
}

if (!(responseKey in response)) {
throw new SeamHttpInvalidResponseError(
path,
String(responseKey),
'which the response does not contain',
)
}

return response[responseKey]
}

const getUrlPrefix = (input: string): string => {
if (canParseUrl(input)) {
const url = new URL(input).toString()
Expand Down
44 changes: 28 additions & 16 deletions src/lib/seam-paginator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Client } from './client.js'
import type { SeamHttpRequestOptions } from './options.js'
import { SeamHttpRequest } from './seam-http-request.js'
import { SeamHttpInvalidResponseError } from './seam-http-error.js'
import { readResponseData, SeamHttpRequest } from './seam-http-request.js'

interface SeamPaginatorParent {
readonly client: Client
Expand Down Expand Up @@ -96,27 +97,38 @@ export class SeamPaginator<
})

const response = await request.fetchResponse()
const data = response[responseKey]
const data = readResponseData(response, responseKey, request.pathname)

const paginationData =
response != null &&
typeof response === 'object' &&
'pagination' in response
? (response.pagination as PaginationData)
: null

const pagination: Pagination = {
hasNextPage: paginationData?.has_next_page ?? false,
nextPageCursor: paginationData?.next_page_cursor ?? null,
nextPageUrl: paginationData?.next_page_url ?? null,
if (!Array.isArray(data)) {
throw new SeamHttpInvalidResponseError(
request.pathname,
String(responseKey),
`got ${data === null ? 'null' : typeof data} instead of a list`,
)
}

if (!Array.isArray(data)) {
throw new Error(
`Expected an array response for ${String(responseKey)} but got ${String(typeof data)}`,
const paginationData = readResponseData(
response as { pagination: unknown },
'pagination',
request.pathname,
)

if (paginationData === null || typeof paginationData !== 'object') {
throw new SeamHttpInvalidResponseError(
request.pathname,
'pagination',
`got ${paginationData === null ? 'null' : typeof paginationData} instead of a pagination object`,
)
}

const paginationResponse = paginationData as PaginationData

const pagination: Pagination = {
hasNextPage: paginationResponse.has_next_page ?? false,
nextPageCursor: paginationResponse.next_page_cursor ?? null,
nextPageUrl: paginationResponse.next_page_url ?? null,
}

return [
data as EnsureReadonlyArray<TResponse[TResponseKey]>,
pagination,
Expand Down
142 changes: 142 additions & 0 deletions test/seam/connect/invalid-response.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import test from 'ava'
import { getTestServer } from 'fixtures/seam/connect/api.js'
import nock from 'nock'

import {
isSeamHttpInvalidResponseError,
SeamHttp,
SeamHttpInvalidResponseError,
} from '@seamapi/http/connect'

const jsonHeaders = { 'Content-Type': 'application/json' }

test('SeamHttpRequest: throws for a success response missing the response key', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint).get('/devices/get').query(true).reply(200, {})

const err = await t.throwsAsync(
async () => await seam.devices.get({ device_id: seed.august_device_1 }),
{
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /devices/get: expected "device", which the response does not contain',
},
)

t.true(isSeamHttpInvalidResponseError(err))
t.is(err?.path, '/devices/get')
t.is(err?.responseKey, 'device')
})

test('SeamHttpRequest: throws for a success response that is not an object', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint)
.get('/devices/get')
.query(true)
.reply(200, JSON.stringify('device'), jsonHeaders)

await t.throwsAsync(
async () => await seam.devices.get({ device_id: seed.august_device_1 }),
{
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /devices/get: expected "device", got string instead of a response object',
},
)
})

test('SeamHttpRequest: throws for a null success response', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint)
.get('/devices/get')
.query(true)
.reply(200, JSON.stringify(null), jsonHeaders)

await t.throwsAsync(
async () => await seam.devices.get({ device_id: seed.august_device_1 }),
{
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /devices/get: expected "device", got null instead of a response object',
},
)
})

test('SeamHttpRequest: throws for a malformed action attempt response', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint).post('/locks/unlock_door').reply(200, {})

await t.throwsAsync(
async () =>
await seam.locks.unlockDoor({ device_id: seed.august_device_1 }),
{
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /locks/unlock_door: expected "action_attempt", which the response does not contain',
},
)
})

test('SeamPaginator: throws for a success response where the response key is not a list', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint)
.get('/devices/list')
.reply(200, {
devices: 'device-1',
pagination: {
has_next_page: false,
next_page_cursor: null,
next_page_url: null,
},
})

const pages = seam.createPaginator(seam.devices.list())

await t.throwsAsync(async () => await pages.firstPage(), {
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /devices/list: expected "devices", got string instead of a list',
})
})

test('SeamPaginator: throws for a success response missing the pagination object', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint).get('/devices/list').reply(200, { devices: [] })

const pages = seam.createPaginator(seam.devices.list())

await t.throwsAsync(async () => await pages.firstPage(), {
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /devices/list: expected "pagination", which the response does not contain',
})
})

test('SeamPaginator: throws for a success response with a non-object pagination value', async (t) => {
const { seed, endpoint } = await getTestServer(t)
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint })

nock(endpoint)
.get('/devices/list')
.reply(200, { devices: [], pagination: 'none' })

const pages = seam.createPaginator(seam.devices.list())

await t.throwsAsync(async () => await pages.firstPage(), {
instanceOf: SeamHttpInvalidResponseError,
message:
'Seam returned an invalid response for /devices/list: expected "pagination", got string instead of a pagination object',
})
})
Loading