diff --git a/CHANGELOG.md b/CHANGELOG.md index f608132..5fa234d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## v1.14.0-dev + +#### Enchancements + +- Add helper function `callCustomEndpoint` to call [Custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) - [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149) + ## v1.13.1 #### Bug fixes diff --git a/README.md b/README.md index b2da524..76f0c9a 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,83 @@ const searchResult = await shopperSearch.productSearch({ Invalid query parameters that are not a part of the API and do not follow the `c_` custom query parameter convention will be filtered from the request and a warning will be displayed. +### Custom APIs + +The SDK supports calling [custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) with a helper function, `callCustomEndpoint`. + +Example usage: + +```javascript +import pkg from 'commerce-sdk-isomorphic'; +const { helpers } = pkg; + +const clientConfigExample = { + parameters: { + clientId: "", + organizationId: "", + shortCode: "", + siteId: "", + }, + // If not provided, it'll use the default production URI: + // 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}' + // path parameters should be wrapped in curly braces like the default production URI + baseUri: "" +}; + +// Required params: apiName, endpointPath, shortCode, organizaitonId +// Required path params can be passed into: +// options.customApiPathParameters or clientConfig.parameters +const customApiArgs = { + apiName: 'loyalty-info', + apiVersion: 'v1', // defaults to v1 if not provided + endpointPath: 'customers' +} + +const accessToken = ''; + +let getResponse = await helpers.callCustomEndpoint({ + options: { + method: 'GET', + parameters: { + queryParameter: 'queryParameter1', + }, + headers: { + // Content-Type is defaulted to application/json if not provided + 'Content-Type': 'application/json', + authorization: `Bearer ${access_token}` + }, + customApiPathParameters: customApiArgs + }, + clientConfig: clientConfigExample, + // Flag to retrieve raw response or data from helper function + rawResponse: false, +}) + +let postResponse = await helpers.callCustomEndpoint({ + options: { + method: 'POST', + parameters: { + queryParameter: 'queryParameter1', + }, + headers: { + authorization: `Bearer ${access_token}` + }, + customApiPathParameters: customApiArgs, + body: JSON.stringify({ data: 'data' }) + }, + clientConfig: clientConfigExample, + // Flag to retrieve raw response or data from helper function + rawResponse: false, +}) + +console.log('get response: ', getResponse) +console.log('post response: ', postResponse) +``` + +For more documentation about this helper function, please refer to the [commerce-sdk-isomorphic docs](https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/modules/helpers.html). + +For more information about custom APIs, please refer to the [Salesforce Developer Docs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) + ## License Information The Commerce SDK Isomorphic is licensed under BSD-3-Clause license. See the [license](./LICENSE.txt) for details. diff --git a/src/static/config.ts b/src/static/config.ts new file mode 100644 index 0000000..70cf190 --- /dev/null +++ b/src/static/config.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +// eslint-disable-next-line import/prefer-default-export +export const CUSTOM_API_DEFAULT_BASE_URI = + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts new file mode 100644 index 0000000..28943d1 --- /dev/null +++ b/src/static/helpers/customApi.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import nock from 'nock'; +import {callCustomEndpoint, CustomParams} from './customApi'; +import * as fetchHelper from './fetchHelper'; +import ClientConfig from '../clientConfig'; + +describe('callCustomEndpoint', () => { + beforeEach(() => { + jest.restoreAllMocks(); + nock.cleanAll(); + }); + + const clientConfig = new ClientConfig({ + parameters: { + shortCode: 'short_code', + organizationId: 'organization_id', + clientId: 'client_id', + siteId: 'site_id', + }, + }); + + const options = { + method: 'POST', + parameters: { + queryParam1: 'query parameter 1', + queryParam2: 'query parameter 2', + }, + customApiPathParameters: { + apiName: 'api_name', + apiVersion: 'v2', + endpointPath: 'endpoint_path', + }, + headers: { + 'Content-Type': 'text/plain', + authorization: 'Bearer token', + }, + body: 'Hello World', + }; + + const queryParamString = new URLSearchParams({ + ...options.parameters, + siteId: clientConfig.parameters.siteId as string, + }).toString(); + + // helper function that creates a copy of the options object + // and adds siteId to the parameters object that comes from clientConfig + const addSiteIdToOptions = (optionsObj: Record) => ({ + ...optionsObj, + parameters: { + ...(optionsObj.parameters as Record), + siteId: clientConfig.parameters.siteId, + }, + }); + + test('throws an error when required path parameters are not passed', () => { + const copyOptions = { + ...options, + // omit endpointPath + customApiPathParameters: { + apiName: 'api_name', + }, + }; + + expect(async () => { + // eslint-disable-next-line + // @ts-ignore <-- we know it'll complain since we removed endpointPath + await callCustomEndpoint({options: copyOptions, clientConfig}); + }) + .rejects.toThrow( + 'Missing required property needed in options.customApiPathParameters or clientConfig.parameters: endpointPath' + ) + .finally(() => 'resolve promise'); + }); + + test('sets api version to "v1" if not provided', async () => { + const copyOptions = { + ...options, + // omit apiVersion + customApiPathParameters: { + endpointPath: 'endpoint_path', + apiName: 'api_name', + }, + }; + + const {shortCode, organizationId} = clientConfig.parameters; + const {apiName, endpointPath} = copyOptions.customApiPathParameters; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/v1/organizations/${ + organizationId as string + }/${endpointPath}`; + nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); + + const expectedUrl = `${ + nockBasePath + nockEndpointPath + }?${queryParamString}`; + const expectedOptions = addSiteIdToOptions(copyOptions); + + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); + + const response = (await callCustomEndpoint({ + options: copyOptions, + clientConfig, + rawResponse: true, + })) as Response; + + expect(response.status).toBe(200); + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( + expectedUrl, + expectedOptions, + expect.anything(), + true + ); + expect(expectedUrl).toContain('/v1/'); + }); + + test('doFetch is called with the correct arguments', async () => { + const {shortCode, organizationId} = clientConfig.parameters; + const {apiName, endpointPath} = options.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).reply(200); + + const expectedUrl = `${ + nockBasePath + nockEndpointPath + }?${queryParamString}`; + const expectedOptions = addSiteIdToOptions(options); + + const expectedClientConfig = { + ...clientConfig, + baseUri: + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}', + }; + + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); + await callCustomEndpoint({options, clientConfig, rawResponse: true}); + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( + expectedUrl, + expectedOptions, + expectedClientConfig, + true + ); + }); + + test('uses path params from options and clientConfig, prioritizing options', async () => { + const copyClientConfig = { + ...clientConfig, + // Only shortCode will be used + parameters: { + endpointPath: 'clientConfig_endpoint_path', + apiName: 'clientConfig_api_name', + shortCode: 'clientconfig_shortcode', + apiVersion: 'v2', + organizationId: 'clientConfig_organizationId', + siteId: 'site_id', + }, + }; + + const copyOptions = { + ...options, + // these parameters will be prioritzed + customApiPathParameters: { + endpointPath: 'customApiPathParameters_endpoint_path', + apiName: 'customApiPathParameters_api_name', + apiVersion: 'v3', + organizationId: 'customApiPathParameters_organizationId', + }, + }; + + // nock interception should be using custom API path parameters from options + const {apiName, endpointPath, organizationId, apiVersion} = + copyOptions.customApiPathParameters; + // except shortcode since we didn't implement it in copyOptions.customApiPathParameters + const {shortCode} = copyClientConfig.parameters; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/${apiVersion}/organizations/${organizationId}/${endpointPath}`; + nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); + + // expected URL is a mix of both params + const expectedUrl = `${ + nockBasePath + nockEndpointPath + }?${queryParamString}`; + + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); + await callCustomEndpoint({ + options: copyOptions, + clientConfig: copyClientConfig, + }); + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( + expectedUrl, + expect.anything(), + expect.anything(), + undefined + ); + }); + + test('uses application/json as default content type if not provided', async () => { + const copyOptions = { + ...options, + // exclude Content-Type + headers: { + authorization: 'Bearer token', + }, + }; + + const {apiName, endpointPath, apiVersion} = + copyOptions.customApiPathParameters; + const {shortCode, organizationId} = clientConfig.parameters; + + const expectedJsonHeaders = { + authorization: 'Bearer token', + 'Content-Type': 'application/json', + }; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/${apiVersion}/organizations/${ + organizationId as string + }/${endpointPath}`; + nock(nockBasePath, { + reqheaders: expectedJsonHeaders, + }) + .post(nockEndpointPath) + .query(true) + .reply(200); + + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); + await callCustomEndpoint({ + options: copyOptions, + clientConfig, + }); + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( + expect.any(String), + expect.objectContaining({headers: expectedJsonHeaders}), + expect.anything(), + undefined + ); + }); +}); diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts new file mode 100644 index 0000000..8655611 --- /dev/null +++ b/src/static/helpers/customApi.ts @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {BodyInit} from 'node-fetch'; +import {PathParameters} from './types'; +import {doFetch} from './fetchHelper'; +import TemplateURL from '../templateUrl'; +import {ClientConfigInit} from '../clientConfig'; +import {CUSTOM_API_DEFAULT_BASE_URI} from '../config'; + +// Helper method to find Content Type header +// returns true if it exists, false otherwise +const contentTypeHeaderExists = ( + headers: Record | undefined +) => { + let foundHeader = false; + if (headers) { + foundHeader = Boolean( + Object.keys(headers).find(key => key.toLowerCase() === 'content-type') + ); + } + return foundHeader; +}; + +export interface CustomParams { + apiName?: string; + apiVersion?: string; + endpointPath?: string; + organizationId?: string; + shortCode: string; + [key: string]: unknown; +} + +/** + * A helper function designed to make calls to a custom API endpoint + * For more information about custom APIs, please refer to the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) + * @param args - Argument object containing data used for custom API request + * @param args.options - An object containing any custom settings you want to apply to the request + * @param args.options.method? - The request HTTP operation. 'GET' is the default if no method is provided. + * @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.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. + * @param args.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. If "Content-Type" is not provided in either header, it will be defaulted to "application/json". + * @param args.clientConfig.baseUri? - baseUri used for the request, where the path parameters are wrapped in curly braces. Default value is 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}' + * @param args.clientConfig.fetchOptions? - fetchOptions that are passed onto the fetch request + * @param args.clientConfig.throwOnBadResponse? - flag that when set true will throw a response error if the fetch request fails (returns with a status code outside the range of 200-299 or 304 redirect) + * @param args.clientConfig.proxy? - Routes API calls through a proxy when set + * @param args.rawResponse? - Flag to return the raw response from the fetch call. True for raw response object, false for the data from the response + * @returns Raw response or data from response based on rawResponse argument from fetch call + */ +export const callCustomEndpoint = async (args: { + options: { + method?: string; + parameters?: { + [key: string]: string | number | boolean | string[] | number[]; + }; + customApiPathParameters?: { + apiName?: string; + apiVersion?: string; + endpointPath?: string; + organizationId?: string; + shortCode?: string; + }; + headers?: { + authorization?: string; + } & {[key: string]: string}; + body?: BodyInit | globalThis.BodyInit | unknown; + }; + clientConfig: ClientConfigInit; + rawResponse?: boolean; +}): Promise => { + const {options, clientConfig, rawResponse} = args; + + const requiredArgs = [ + 'apiName', + 'endpointPath', + 'organizationId', + 'shortCode', + ]; + + const pathParams: Record = { + ...clientConfig.parameters, + ...options?.customApiPathParameters, + }; + + requiredArgs.forEach(arg => { + if (!pathParams[arg]) { + throw new Error( + `Missing required property needed in options.customApiPathParameters or clientConfig.parameters: ${arg}` + ); + } + }); + + if (!pathParams.apiVersion) { + pathParams.apiVersion = 'v1'; + } + + let clientConfigCopy = clientConfig; + if (!clientConfig.baseUri) { + clientConfigCopy = { + ...clientConfig, + baseUri: CUSTOM_API_DEFAULT_BASE_URI, + }; + } + + // Use siteId from clientConfig if it is not defined in options and is available in clientConfig + const useSiteId = Boolean( + !options.parameters?.siteId && clientConfig?.parameters?.siteId + ); + const contentTypeExists = + contentTypeHeaderExists(options.headers) || + contentTypeHeaderExists(clientConfigCopy.headers); + + let optionsCopy = options; + + if (!contentTypeExists || useSiteId) { + optionsCopy = { + ...options, + headers: { + ...options.headers, + // If Content-Type header does not exist, we default to "Content-Type": "application/json" + ...(!contentTypeExists && {'Content-Type': 'application/json'}), + }, + parameters: { + ...options.parameters, + ...(useSiteId && {siteId: clientConfig.parameters.siteId as string}), + }, + }; + } + + const url = new TemplateURL( + '/organizations/{organizationId}/{endpointPath}', + clientConfigCopy.baseUri as string, + { + pathParams: pathParams as PathParameters, + queryParams: optionsCopy.parameters, + origin: clientConfigCopy.proxy, + } + ); + + return doFetch(url.toString(), optionsCopy, clientConfigCopy, rawResponse); +}; diff --git a/src/static/helpers/environment.ts b/src/static/helpers/environment.ts index 1014383..49ba461 100644 --- a/src/static/helpers/environment.ts +++ b/src/static/helpers/environment.ts @@ -32,6 +32,8 @@ export const fetch: FetchFunction = (() => { return require('node-fetch').default; } + // difficult to test in node environment + /* istanbul ignore next */ if (!hasFetchAvailable) throw new Error( 'Bad environment: it is not a node environment but fetch is not defined' diff --git a/src/static/helpers/fetchHelper.test.ts b/src/static/helpers/fetchHelper.test.ts new file mode 100644 index 0000000..f88a07d --- /dev/null +++ b/src/static/helpers/fetchHelper.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import nock from 'nock'; +import {Response} from 'node-fetch'; +import * as environment from './environment'; +import ClientConfig from '../clientConfig'; +import {doFetch} from './fetchHelper'; + +describe('doFetch', () => { + const basePath = 'https://short_code.api.commercecloud.salesforce.com'; + const endpointPath = + '/checkout/shopper-baskets/v1/organizations/organization_id/baskets'; + const url = `${basePath + endpointPath}?siteId=site_id`; + + const clientConfig = new ClientConfig({ + parameters: { + shortCode: 'short_code', + organizationId: 'organization_id', + clientId: 'client_id', + siteId: 'site_id', + }, + fetchOptions: { + cache: 'no-cache', + }, + }); + + const options = { + method: 'POST', + headers: { + authorization: 'Bearer token', + }, + body: { + data: 'data', + }, + }; + + const responseBody = {message: 'request has matched'}; + + beforeEach(() => { + jest.restoreAllMocks(); + nock.cleanAll(); + }); + + test('uses headers from both clientConfig and headers object', async () => { + const copyOptions = { + ...options, + headers: { + ...options.headers, + optionsOnlyHeader: 'optionsOnlyHeader', + repeatHeader: 'options.headers', + }, + }; + + const copyClientConfig = { + ...clientConfig, + headers: { + ...clientConfig.headers, + clientConfigOnlyHeader: 'clientConfigOnlyHeader', + repeatHeader: 'clientConfig.headers', // this should get overwritten + }, + }; + + const expectedHeaders = { + authorization: 'Bearer token', + optionsOnlyHeader: 'optionsOnlyHeader', + clientConfigOnlyHeader: 'clientConfigOnlyHeader', + repeatHeader: 'options.headers', + // we should not see this header as repeatHeader in options should override this one + // repeatHeader: 'clientConfig.headers', + }; + + nock(basePath, { + reqheaders: expectedHeaders, + }) + .post(endpointPath) + .query({siteId: 'site_id'}) + .reply(200, responseBody); + + const spy = jest.spyOn(environment, 'fetch'); + + const response = await doFetch(url, copyOptions, copyClientConfig); + expect(response).toEqual(responseBody); + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + expect.any(String), + expect.objectContaining({headers: expectedHeaders}) + ); + }); + + test('returns raw response when rawResponse flag is passed as true', async () => { + nock(basePath) + .post(endpointPath) + .query({siteId: 'site_id'}) + .reply(200, responseBody); + + const response = (await doFetch( + url, + options, + clientConfig, + true + )) as Response; + expect(response instanceof Response).toBe(true); + + const data = (await response.json()) as Record; + expect(data).toEqual(responseBody); + }); + + test('throws error when clientConfig.throwOnBadResponse is true and fetch call fails', () => { + nock(basePath).post(endpointPath).query({siteId: 'site_id'}).reply(400); + + const copyClientConfig = {...clientConfig, throwOnBadResponse: true}; + expect(async () => { + await doFetch(url, options, copyClientConfig); + }) + .rejects.toThrow('400 Bad Request') + .finally(() => 'resolve promise'); + }); + + test('returns data from response when rawResponse flag is passed as false or not passed', async () => { + nock(basePath).post(endpointPath).query(true).reply(200, responseBody); + + const data = await doFetch(url, options, clientConfig, false); + expect(data).toEqual(responseBody); + }); + + test('passes on fetchOptions from clientConfig to fetch call', async () => { + nock(basePath).post(endpointPath).query(true).reply(200, responseBody); + + const spy = jest.spyOn(environment, 'fetch'); + await doFetch(url, options, clientConfig, false); + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + expect.any(String), + expect.objectContaining(clientConfig.fetchOptions) + ); + }); +}); diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts new file mode 100644 index 0000000..7b9cbd9 --- /dev/null +++ b/src/static/helpers/fetchHelper.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {BodyInit} from 'node-fetch'; +import {BaseUriParameters} from '.'; +import type {FetchOptions} from '../clientConfig'; +import ResponseError from '../responseError'; +import {fetch} from './environment'; +import {ClientConfigInit} from '../clientConfig'; + +/** + * A wrapper function around fetch designed for making requests using the SDK + * @param url - The url of the resource that you wish to fetch + * @param options? - An object containing any custom settings you want to apply to the request + * @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 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 + * @param clientConfig.throwOnBadResponse? - flag that when set true will throw a response error if the fetch request fails + * @param rawResponse? - Flag to return the raw response from the fetch call. True for raw response object, false for the data from the response + * @returns Raw response or data from response based on rawResponse argument from fetch call + */ +// eslint-disable-next-line import/prefer-default-export +export const doFetch = async ( + url: string, + options?: { + method?: string; + headers?: { + authorization?: string; + } & {[key: string]: string}; + body?: BodyInit | globalThis.BodyInit | unknown; + }, + clientConfig?: ClientConfigInit, + rawResponse?: boolean +): Promise => { + const headers: Record = { + ...clientConfig?.headers, + ...options?.headers, + }; + + const requestOptions: FetchOptions = { + ...clientConfig?.fetchOptions, + headers, + body: options?.body as + | (BodyInit & (globalThis.BodyInit | null)) + | undefined, + method: options?.method ?? 'GET', + }; + + const response = await fetch(url, requestOptions); + if (rawResponse) { + return response; + } + if ( + clientConfig?.throwOnBadResponse && + !response.ok && + response.status !== 304 + ) { + throw new ResponseError(response); + } else { + const text = await response.text(); + // It's ideal to get "{}" for an empty response body, but we won't throw if it's truly empty + return (text ? JSON.parse(text) : {}) as unknown | Response; + } +}; diff --git a/src/static/helpers/index.ts b/src/static/helpers/index.ts index 452a5cd..f72986b 100644 --- a/src/static/helpers/index.ts +++ b/src/static/helpers/index.ts @@ -9,3 +9,5 @@ export * from './environment'; export * from './slasHelper'; export * from './types'; +export * from './customApi'; +export * from './fetchHelper'; diff --git a/templates/client.ts.hbs b/templates/client.ts.hbs index 4a8ac30..9821da1 100644 --- a/templates/client.ts.hbs +++ b/templates/client.ts.hbs @@ -1,12 +1,12 @@ import ClientConfig, { ClientConfigInit } from "./clientConfig"; // Must not import from ./helpers/index to avoid circular dependency via ShopperLogin -import { isBrowser, fetch } from "./helpers/environment"; +import { isBrowser } from "./helpers/environment"; +import { doFetch } from "./helpers"; import type { BaseUriParameters, CompositeParameters, RequireParametersUnlessAllAreOptional } from "./helpers/types"; -import ResponseError from "./responseError"; import TemplateURL from "./templateUrl"; import { USER_AGENT_HEADER, USER_AGENT_VALUE } from "./version"; diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index f77bc3d..0961226 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -187,26 +187,24 @@ headers[USER_AGENT_HEADER] = [headers[USER_AGENT_HEADER], USER_AGENT_VALUE].join(" "); } - const requestOptions = { - ...this.clientConfig.fetchOptions, - {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers),{{/if}} - headers, - method: "{{loud method}}" - }; + const response = await doFetch( + url.toString(), + { + method: "{{loud method}}", + headers, + {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers){{/if}} + }, + this.clientConfig, + rawResponse + ) - const response = await fetch(url.toString(), requestOptions); + {{#if (eq (getReturnTypeFromOperation this) "void") }} if (rawResponse) { - return response; - } else if (this.clientConfig.throwOnBadResponse && !response.ok && response.status !== 304) { - throw new ResponseError(response); + return response as Response; } - {{#unless (eq (getReturnTypeFromOperation this) "void") }} - else { - const text = await response.text(); - // It's ideal to get "{}" for an empty response body, but we won't throw if it's truly empty - return text ? JSON.parse(text) : {}; - } - {{/unless}} + {{else}} + return response as Response | {{getReturnTypeFromOperation this}}; + {{/if}} } {{/each}} {{/each}}