diff --git a/README.md b/README.md index 59a9fd52..e66d143c 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,12 @@ console.log(`${request.method} ${request.url}`, JSON.stringify(request.body)) const devices = await request.execute() ``` +A `SeamHttpRequest` is sent at most once. +Awaiting the same request again, +or calling `execute`, `then`, `catch`, or `finally` more than once, +always returns the result of the first execution +and never repeats the HTTP request. + #### Serializing URL search params The Seam API parses URL search params as complex types. diff --git a/src/lib/seam-http-request.ts b/src/lib/seam-http-request.ts index 719de08d..b5be30ad 100644 --- a/src/lib/seam-http-request.ts +++ b/src/lib/seam-http-request.ts @@ -36,6 +36,11 @@ interface SeamHttpRequestConfig { * The request is sent once `execute` is called, * or when the request is awaited like a Promise, * e.g., with `await`, `then`, `catch`, or `finally`. + * The request is sent at most once: + * awaiting the same SeamHttpRequest again, + * or calling `execute`, `then`, `catch`, or `finally` more than once, + * always returns the result of the first execution + * and never repeats the HTTP request. * When the response contains an action attempt, * awaiting the request also waits for the action attempt to resolve * according to the `waitForActionAttempt` option. @@ -54,6 +59,12 @@ export class SeamHttpRequest< readonly #parent: SeamHttpRequestParent readonly #config: SeamHttpRequestConfig + #executePromise: Promise< + TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined + > | null = null + + #fetchResponsePromise: Promise | null = null + constructor( parent: SeamHttpRequestParent, config: SeamHttpRequestConfig, @@ -118,9 +129,19 @@ export class SeamHttpRequest< * If the response contains an action attempt, * waits for the action attempt to resolve * according to the `waitForActionAttempt` option. + * The request is sent at most once: + * calling this method again returns the result of the first call + * and never repeats the HTTP request. */ async execute(): Promise< TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined + > { + this.#executePromise ??= this.#execute() + return await this.#executePromise + } + + async #execute(): Promise< + TResponseKey extends keyof TResponse ? TResponse[TResponseKey] : undefined > { const response = await this.fetchResponse() @@ -160,8 +181,16 @@ export class SeamHttpRequest< /** * Sends the request and returns the entire response body * without waiting for any action attempt to resolve. + * The request is sent at most once: + * calling this method again returns the result of the first call + * and never repeats the HTTP request. */ async fetchResponse(): Promise { + this.#fetchResponsePromise ??= this.#fetchResponse() + return await this.#fetchResponsePromise + } + + async #fetchResponse(): Promise { assertValidRequestParameters( this.#config.parameters, this.pathname, diff --git a/test/seam/connect/seam-http-request.test.ts b/test/seam/connect/seam-http-request.test.ts index 00e69b5d..2a79f166 100644 --- a/test/seam/connect/seam-http-request.test.ts +++ b/test/seam/connect/seam-http-request.test.ts @@ -172,6 +172,113 @@ test.serial( }, ) +test('SeamHttpRequest: sends the request at most once when awaited more than once', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: seed.august_device_1 }) + + const device = await request + const deviceAgain = await request + const [deviceOnceMore] = await Promise.all([request, request]) + + t.is(requestCount, 1) + t.is(device.device_id, seed.august_device_1) + t.is(deviceAgain.device_id, seed.august_device_1) + t.is(deviceOnceMore?.device_id, seed.august_device_1) +}) + +test('SeamHttpRequest: catch does not send the request again', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: seed.august_device_1 }) + + const device = await request + await request.catch(() => { + t.fail('should not reject') + }) + + t.is(requestCount, 1) + t.is(device.device_id, seed.august_device_1) +}) + +test('SeamHttpRequest: finally does not send the request again', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: seed.august_device_1 }) + + const device = await request + const deviceAgain = await request.finally(() => {}) + + t.is(requestCount, 1) + t.is(device.device_id, seed.august_device_1) + t.is(deviceAgain.device_id, seed.august_device_1) +}) + +test('SeamHttpRequest: sends a write request at most once', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + waitForActionAttempt: false, + }) + + let writeCount = 0 + seam.client.interceptors.request.use((config) => { + if (config.url === '/locks/unlock_door') writeCount++ + return config + }) + + const request = seam.locks.unlockDoor({ device_id: seed.august_device_1 }) + + const actionAttempt = await request + request.catch(() => { + t.fail('should not reject') + }) + const actionAttemptAgain = await request + + t.is(writeCount, 1) + t.is(actionAttempt.action_attempt_id, actionAttemptAgain.action_attempt_id) +}) + +test('SeamHttpRequest: a rejected request stays rejected when awaited again', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + let requestCount = 0 + seam.client.interceptors.request.use((config) => { + requestCount++ + return config + }) + + const request = seam.devices.get({ device_id: 'unknown-device-id' }) + + const err = await t.throwsAsync(async () => await request) + const errAgain = await t.throwsAsync(async () => await request) + + t.is(requestCount, 1) + t.is(err, errAgain) +}) + const toPlainUrlObject = (url: URL): Omit => { return { pathname: url.pathname,