diff --git a/src/lib/seam-http-error.ts b/src/lib/seam-http-error.ts index e16f3c2e..e6450e3a 100644 --- a/src/lib/seam-http-error.ts +++ b/src/lib/seam-http-error.ts @@ -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 +} diff --git a/src/lib/seam-http-request.ts b/src/lib/seam-http-request.ts index 719de08d..9649c5e7 100644 --- a/src/lib/seam-http-request.ts +++ b/src/lib/seam-http-request.ts @@ -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 { @@ -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 = @@ -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() diff --git a/src/lib/seam-paginator.ts b/src/lib/seam-paginator.ts index 0741f46f..9c84bd5a 100644 --- a/src/lib/seam-paginator.ts +++ b/src/lib/seam-paginator.ts @@ -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 @@ -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, pagination, diff --git a/test/seam/connect/invalid-response.test.ts b/test/seam/connect/invalid-response.test.ts new file mode 100644 index 00000000..abf7e0d9 --- /dev/null +++ b/test/seam/connect/invalid-response.test.ts @@ -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', + }) +})