diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index 28943d1b..f36f2180 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -249,4 +249,54 @@ describe('callCustomEndpoint', () => { undefined ); }); + + test('doFetch is called with signal from options and throws aborted error when aborted', async () => { + const controller = new AbortController(); + + const copyOptions = { + ...options, + signal: controller.signal, + }; + + const {shortCode, organizationId} = clientConfig.parameters; + const {apiName, endpointPath} = copyOptions.customApiPathParameters; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/v2/organizations/${ + organizationId as string + }/${endpointPath}`; + nock(nockBasePath) + .post(nockEndpointPath) + .query(true) + .delayConnection(200) + .reply(200); + + const expectedUrl = `${ + nockBasePath + nockEndpointPath + }?${queryParamString}`; + const expectedOptions = addSiteIdToOptions(copyOptions); + + const expectedClientConfig = { + ...clientConfig, + baseUri: + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}', + }; + + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); + setTimeout(() => controller.abort(), 100); + await expect( + callCustomEndpoint({ + options: copyOptions, + clientConfig, + rawResponse: true, + }) + ).rejects.toThrow('The user aborted a request.'); + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( + expectedUrl, + expectedOptions, + expectedClientConfig, + true + ); + }); }); diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 86556110..658cf09e 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -43,6 +43,7 @@ export interface CustomParams { * @param args.options.parameters? - Query parameters that are added to the request * @param args.options.customApiPathParameters? - Path parameters used for custom API. Required path parameters (apiName, endpointPath, organizationId, and shortCode) can be in this object, or args.clientConfig.parameters. apiVersion is defaulted to 'v1' if not provided. * @param args.options.headers? - Headers that are added to the request. Authorization header should be in this parameter or in the clientConfig.headers. If "Content-Type" is not provided in either header, it will be defaulted to "application/json". + * @param args.options.signal? - An AbortSignal object that can be used to cancel the fetch request * @param args.options.body? - Body that is used for the request * @param args.clientConfig - Client Configuration object used by the SDK with properties that can affect the fetch call * @param args.clientConfig.parameters - Path parameters used for custom API endpoints. The required properties are: apiName, endpointPath, organizationId, and shortCode. An error will be thrown if these are not provided. diff --git a/src/static/helpers/fetchHelper.test.ts b/src/static/helpers/fetchHelper.test.ts index f88a07dc..9e8815e5 100644 --- a/src/static/helpers/fetchHelper.test.ts +++ b/src/static/helpers/fetchHelper.test.ts @@ -138,4 +138,71 @@ describe('doFetch', () => { expect.objectContaining(clientConfig.fetchOptions) ); }); + + test('throws error when fetchOptions.signal is passed and aborted during fetch call', async () => { + nock(basePath) + .post(endpointPath) + .query(true) + .delayConnection(200) + .reply(200, responseBody); + + const controller = new AbortController(); + const copyClientConfig = { + ...clientConfig, + fetchOptions: {...clientConfig.fetchOptions, signal: controller.signal}, + }; + setTimeout(() => controller.abort(), 100); + const spy = jest.spyOn(environment, 'fetch'); + await expect( + doFetch(url, options, copyClientConfig, false) + ).rejects.toThrow('The user aborted a request.'); + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + expect.any(String), + expect.objectContaining({signal: controller.signal}) + ); + }); + + test('throws error when options.signal is passed and aborted during fetch call', async () => { + nock(basePath) + .post(endpointPath) + .query(true) + .delayConnection(200) + .reply(200, responseBody); + + const controller = new AbortController(); + const copyOptions = {...options, signal: controller.signal}; + setTimeout(() => controller.abort(), 100); + const spy = jest.spyOn(environment, 'fetch'); + await expect( + doFetch(url, copyOptions, clientConfig, false) + ).rejects.toThrow('The user aborted a request.'); + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + expect.any(String), + expect.objectContaining({signal: controller.signal}) + ); + }); + + test('options.signal overrides fetchOptions.signal', async () => { + nock(basePath).post(endpointPath).query(true).reply(200, responseBody); + + const clientConfigController = new AbortController(); + const optionsController = new AbortController(); + const copyClientConfig = { + ...clientConfig, + fetchOptions: { + ...clientConfig.fetchOptions, + signal: clientConfigController.signal, + }, + }; + const copyOptions = {...options, signal: optionsController.signal}; + const spy = jest.spyOn(environment, 'fetch'); + await doFetch(url, copyOptions, copyClientConfig, false); + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + expect.any(String), + expect.objectContaining({signal: optionsController.signal}) + ); + }); }); diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts index 7b9cbd92..6518f668 100644 --- a/src/static/helpers/fetchHelper.ts +++ b/src/static/helpers/fetchHelper.ts @@ -18,6 +18,7 @@ import {ClientConfigInit} from '../clientConfig'; * @param options.method? - The request HTTP operation. 'GET' is the default if no method is provided. * @param options.headers? - Headers that are added to the request. Authorization header should be in this argument or in the clientConfig.headers * @param options.body? - Body that is used for the request + * @param options.signal? - An AbortSignal object that can be used to cancel the fetch request * @param clientConfig? - Client Configuration object used by the SDK with properties that can affect the fetch call * @param clientConfig.headers? - Additional headers that are added to the request. Authorization header should be in this argument or in the options?.headers. options?.headers will override any duplicate properties. * @param clientConfig.fetchOptions? - fetchOptions that are passed onto the fetch request @@ -34,6 +35,7 @@ export const doFetch = async ( authorization?: string; } & {[key: string]: string}; body?: BodyInit | globalThis.BodyInit | unknown; + signal?: AbortSignal; }, clientConfig?: ClientConfigInit, rawResponse?: boolean @@ -50,6 +52,7 @@ export const doFetch = async ( | (BodyInit & (globalThis.BodyInit | null)) | undefined, method: options?.method ?? 'GET', + ...(options?.signal ? {signal: options.signal} : {}), }; const response = await fetch(url, requestOptions); diff --git a/src/test/crossFetchNode.test.ts b/src/test/crossFetchNode.test.ts index c900e4dc..56f2d91d 100644 --- a/src/test/crossFetchNode.test.ts +++ b/src/test/crossFetchNode.test.ts @@ -372,3 +372,34 @@ test('throwOnBadResponse flag defaults to false', async () => { expect(response).toEqual({content: 'not empty'}); }); + +test('should use signal from fetch options and throw aborted error', async () => { + nock('https://localhost:3000') + .get( + `/search/shopper-search/v1/organizations/${config.parameters.organizationId}/product-search?siteId=${config.parameters.siteId}&q=sony` + ) + .matchHeader('authorization', 'Bearer test-auth') + .delayConnection(200) + .reply(200, {}, {'content-type': 'application-json charset=UTF-8'}); + + const abortController = new AbortController(); + const clientConfig: ClientConfigInit = { + ...config, + fetchOptions: { + signal: abortController.signal, + }, + }; + + expect.assertions(1); + const client = new ShopperSearch(clientConfig); + setTimeout(() => abortController.abort(), 100); + await expect( + client.productSearch({ + parameters: {q: 'sony'}, + headers: {authorization: 'Bearer test-auth'}, + }) + ).rejects.toEqual({ + message: 'The user aborted a request.', + type: 'aborted', + }); +}); diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 93f8fb1e..ab37c4ae 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -16,6 +16,7 @@ {{/each}} * @param headers - An object literal of key value pairs of the headers to be * sent with this request. + * @param signal - An AbortSignal object that can be used to cancel the fetch request {{#if (isRequestWithPayload request)}} * @param body - The data to send as the request body. {{/if}} @@ -36,6 +37,7 @@ {{/each}} } & { [key in `c_${string}`]: any }, ConfigParameters>, headers?: { [key: string]: string }, + signal?: AbortSignal, {{#if (isRequestWithPayload request)}} body: {{{getPayloadTypeFromRequest request}}} {{/if}} @@ -55,6 +57,7 @@ {{/each}} * @param headers - An object literal of key value pairs of the headers to be * sent with this request. + * @param signal - An AbortSignal object that can be used to cancel the fetch request {{#if (isRequestWithPayload request)}} * @param body - The data to send as the request body. {{/if}} @@ -75,6 +78,7 @@ {{/each}} } & { [key in `c_${string}`]: any }, ConfigParameters>, headers?: { [key: string]: string }, + signal?: AbortSignal, {{#if (isRequestWithPayload request)}} body: {{{getPayloadTypeFromRequest request}}} {{/if}} @@ -95,6 +99,7 @@ {{/each}} * @param headers - An object literal of key value pairs of the headers to be * sent with this request. + * @param signal - An AbortSignal object that can be used to cancel the fetch request {{#if (isRequestWithPayload request)}} * @param body - The data to send as the request body. {{/if}} @@ -116,6 +121,7 @@ {{/each}} } & { [key in `c_${string}`]: any }, ConfigParameters>, headers?: { [key: string]: string }, + signal?: AbortSignal, {{#if (isRequestWithPayload request)}} body: {{{getPayloadTypeFromRequest request}}} {{/if}} @@ -193,6 +199,7 @@ { method: "{{loud method}}", headers, + signal: options?.signal, {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers){{/if}} }, this.clientConfig,