diff --git a/CHANGELOG.md b/CHANGELOG.md index 5313527b..0a812327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ you import the type definitions directly. | ------------- |-------------| | redeemCoupon | Redeems a coupon code for an existing coupon within the selected site. The coupon code must be redeemable. | +#### Enchancements + +- Add helper function `customApiHelper.callCustomEndpoint` to call [Custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) - [#402](https://github.com/SalesforceCommerceCloud/commerce-sdk/pull/402) + #### Other Changes - Removal of deprecated helper function `getShopperToken` diff --git a/README.md b/README.md index e8a933bc..8ed95ed8 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,91 @@ const searchResults = await searchClient.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, `customApiHelper.callCustomEndpoint()`. + +Example usage: + +```javascript +import * as CommerceSdk from "commerce-sdk"; +const { helpers } = CommerceSdk; + +// client configuration parameters +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: '' +}; + +const access_token = '' + +// Required params: apiName, endpointPath, shortCode, organizaitonId +// Required path params can be passed into: +// options.customApiPathParameters or clientConfig.parameters +// customApiPathParameters will take priority for duplicate values +const customApiArgs = { + apiName: 'loyalty-info', + apiVersion: 'v1', // defaults to v1 if not provided + endpointPath: 'customers' +} + +const getResponse = await helpers.callCustomEndpoint({ + options: { + // http operation is defaulted to 'GET' if not provided + 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 +}) + +const postResponse = await customApiHelper.callCustomEndpoint({ + options: { + method: 'POST', + headers: { + authorization: `Bearer ${access_token}` + }, + customApiPathParameters: { + apiVersion: 'v1', + endpointPath: 'greeting', + apiName: 'e2e-tests', + }, + // When this flag is set to true, the request body will be automatically + // formatted in the expected format set by the 'Content-type' headers + // 'application/json' or 'application/x-www-form-urlencoded' + enableTransformBody: true, + + // object can be passed since we have enableTransformBody set to true + body: { data: 'data' } + // if enableTransformBody is not set to true, + // we have to ensure the request body is correctly formatted + // body: JSON.stringify({ data: 'data' }) + }, + clientConfig: clientConfigExample, + rawResponse: false +}) + +console.log('get response: ', getResponse) +console.log('post response: ', postResponse) +``` + ## Caching The SDK currently supports two types of caches - In-memory and Redis. Both the implementations respect [standard cache headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). To use another type of cache, write your own implementation of the [CacheManager](https://github.com/SalesforceCommerceCloud/commerce-sdk-core/tree/main/src/base/cacheManager.ts). See the [default cache manager](https://github.com/SalesforceCommerceCloud/commerce-sdk-core/tree/main/src/base/cacheManagerKeyv.ts) to design your implementation. diff --git a/package-lock.json b/package-lock.json index 41a69e39..cc96f40c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.16.0", "license": "BSD-3-Clause", "dependencies": { - "@commerce-apps/core": "^1.6.1", + "@commerce-apps/core": "^1.7.0", "nanoid": "^3.3.4", "retry": "^0.13.1", "tslib": "^2.4.1" @@ -293,9 +293,9 @@ } }, "node_modules/@commerce-apps/core": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@commerce-apps/core/-/core-1.6.1.tgz", - "integrity": "sha512-ZU2T1hhHjZYx65WD1OGf8XIvIXm1IEN4KicUXVdHYjI0n7Vi8cToWkbpr3yt8eFVdhZyLHfUpZ83DemvRnxUyA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@commerce-apps/core/-/core-1.7.0.tgz", + "integrity": "sha512-r2gKvYoMv48iF0zn/865Nt629suReXVWEgir0rVOSNwQPBMTDdN9hYPCbFJPOyNkNQEwMFUvkd8utgwhHXtuxQ==", "dependencies": { "@keyv/redis": "^2.2.2", "dotenv": "^8.6.0", @@ -13100,9 +13100,9 @@ } }, "@commerce-apps/core": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@commerce-apps/core/-/core-1.6.1.tgz", - "integrity": "sha512-ZU2T1hhHjZYx65WD1OGf8XIvIXm1IEN4KicUXVdHYjI0n7Vi8cToWkbpr3yt8eFVdhZyLHfUpZ83DemvRnxUyA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@commerce-apps/core/-/core-1.7.0.tgz", + "integrity": "sha512-r2gKvYoMv48iF0zn/865Nt629suReXVWEgir0rVOSNwQPBMTDdN9hYPCbFJPOyNkNQEwMFUvkd8utgwhHXtuxQ==", "requires": { "@keyv/redis": "^2.2.2", "dotenv": "^8.6.0", diff --git a/package.json b/package.json index 61f28e20..fae63c07 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ ] }, "dependencies": { - "@commerce-apps/core": "^1.6.1", + "@commerce-apps/core": "^1.7.0", "nanoid": "^3.3.4", "retry": "^0.13.1", "tslib": "^2.4.1" diff --git a/src/static/helperTemplates/index.ts.hbs b/src/static/helperTemplates/index.ts.hbs index d9942ab8..854f113d 100644 --- a/src/static/helperTemplates/index.ts.hbs +++ b/src/static/helperTemplates/index.ts.hbs @@ -5,6 +5,8 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import { callCustomEndpoint } from "./customApi"; +export const helpers = { + callCustomEndpoint +} export * as slasHelpers from "./slas"; -export const helpers = {} -{{!-- ^ TODO: fix this once custom API PR gets merged in --}} diff --git a/src/static/helpers/config.ts b/src/static/helpers/config.ts new file mode 100644 index 00000000..d7e07631 --- /dev/null +++ b/src/static/helpers/config.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2022, 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 + */ +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 00000000..82abead9 --- /dev/null +++ b/src/static/helpers/customApi.test.ts @@ -0,0 +1,289 @@ +/* + * 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, CustomApiParameters } from "./customApi"; +import { expect } from "chai"; +import sinon from "sinon"; +import { + ClientConfig, + Response, + StaticClient, + CommonParameters, +} from "@commerce-apps/core"; +import { CUSTOM_API_DEFAULT_BASE_URI } from "./config"; +import { QueryParameters } from "@commerce-apps/core/dist/base/resource"; + +describe("callCustomEndpoint", () => { + const runFetchSpy = sinon.spy(StaticClient, "runFetch"); + + beforeEach(() => { + runFetchSpy.resetHistory(); + nock.cleanAll(); + }); + + const clientConfig: 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", + }; + + it("throws an error when required path parameters are not passed", async () => { + const copyOptions = { + ...options, + // omit endpointPath + customApiPathParameters: { + apiName: "api_name", + }, + }; + + try { + await callCustomEndpoint({ options: copyOptions, clientConfig }); + // istanbul ignore next + expect(true).to.equal(false); // fails if we don't catch an error + } catch (error) { + expect(runFetchSpy.callCount).to.equal(0); + expect(error.toString()).to.equal( + "Error: Missing required property needed in options.customApiPathParameters or clientConfig.parameters: endpointPath" + ); + } + }); + + it('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 as CustomApiParameters; + 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 response = (await callCustomEndpoint({ + options: copyOptions, + clientConfig, + rawResponse: true, + })) as Response; + + expect(response.status).to.equal(200); + + const runFetchPassedArgs = runFetchSpy.getCall(0).args; + expect(runFetchSpy.callCount).to.equal(1); + // commerce-sdk-core expects apiVersion in clientConfig.parameters + expect( + ( + runFetchPassedArgs[1].client.clientConfig + .parameters as CustomApiParameters + ).apiVersion + ).to.equal("v1"); + }); + + it("runFetch is called with the correct arguments", async () => { + const { shortCode, organizationId } = + clientConfig.parameters as CommonParameters; + 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); + + await callCustomEndpoint({ options, clientConfig, rawResponse: true }); + + const runFetchPassedArgs = runFetchSpy.getCall(0).args; + + const expectedPathParms = { + ...clientConfig.parameters, + ...options.customApiPathParameters, + }; + + expect(runFetchSpy.callCount).to.equal(1); + expect(runFetchPassedArgs[0]).to.equal("post"); + expect(runFetchPassedArgs[1].client).to.deep.equal({ + clientConfig: { + ...clientConfig, + baseUri: CUSTOM_API_DEFAULT_BASE_URI, + parameters: expectedPathParms, + }, + }); + expect(runFetchPassedArgs[1].pathParameters).to.deep.equal( + expectedPathParms + ); + expect(runFetchPassedArgs[1].queryParameters).to.deep.equal({ + ...options.parameters, + siteId: (clientConfig.parameters as CommonParameters).siteId as string, + }); + expect(runFetchPassedArgs[1].headers).to.deep.equal(options.headers); + expect(runFetchPassedArgs[1].rawResponse).to.equal(true); + expect(runFetchPassedArgs[1].body).to.equal(options.body); + }); + + it("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); + + await callCustomEndpoint({ + options: copyOptions, + clientConfig: copyClientConfig, + }); + + const runFetchPassedArgs = runFetchSpy.getCall(0).args; + + const expectedPathParams = { + shortCode: "clientconfig_shortcode", + siteId: "site_id", + endpointPath: "customApiPathParameters_endpoint_path", + apiName: "customApiPathParameters_api_name", + apiVersion: "v3", + organizationId: "customApiPathParameters_organizationId", + }; + + expect(runFetchSpy.callCount).to.equal(1); + expect(runFetchPassedArgs[1].pathParameters).to.deep.equal( + expectedPathParams + ); + expect(runFetchPassedArgs[1].client.clientConfig.parameters).to.deep.equal( + expectedPathParams + ); + }); + + it("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 as CommonParameters; + + 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); + + await callCustomEndpoint({ + options: copyOptions, + clientConfig, + }); + + const runFetchPassedArgs = runFetchSpy.getCall(0).args; + expect(runFetchSpy.callCount).to.equal(1); + expect(runFetchPassedArgs[1].headers).to.deep.equal(expectedJsonHeaders); + }); + + it("uses siteId in options over clientConfig if available", async () => { + const copyOptions = { + ...options, + parameters: { + ...options.parameters, + siteId: "customApiPathParameters_siteId", + }, + }; + + const { apiName, endpointPath, apiVersion } = + copyOptions.customApiPathParameters; + + const { shortCode, organizationId } = + clientConfig.parameters as CustomApiParameters; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/${apiVersion}/organizations/${ + organizationId as string + }/${endpointPath}`; + nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); + + await callCustomEndpoint({ + options: copyOptions, + clientConfig, + }); + + const runFetchPassedArgs = runFetchSpy.getCall(0).args; + expect(runFetchSpy.callCount).to.equal(1); + expect( + (runFetchPassedArgs[1].queryParameters as QueryParameters).siteId + ).to.equal(copyOptions.parameters.siteId); + }); +}); diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts new file mode 100644 index 00000000..77d2f4be --- /dev/null +++ b/src/static/helpers/customApi.ts @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2022, 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 { BodyInit, RequestInit } from "node-fetch"; +import { + ClientConfig, + Response, + StaticClient, + CommonParameters, +} from "@commerce-apps/core"; +import { PathParameters } from "@commerce-apps/core/dist/base/resource"; +import type { OperationOptions } from "retry"; +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 type CustomApiParameters = { + organizationId?: string; + shortCode?: string; + endpointName?: string; + apiName?: string; + apiVersion?: string; +}; + +// tsdoc doesn't support dot notation for @param +/* eslint-disable tsdoc/syntax */ +/** + * 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, where this object will take priority for duplicates. 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. The body will be automatically formatted for Content-Type application/json and application/x-www-form-urlencoded + * @param args.options.retrySettings? - Object for facilitating request retries. For more information, please refer to the [README](https://github.com/SalesforceCommerceCloud/commerce-sdk?tab=readme-ov-file#retry-policies) + * @param args.options.fetchOptions? - fetchOptions that are passed onto the fetch request, where this will take precedence over clientConfig.fetchOptions + * @param args.options.enableTransformBody? - Flag when set to true will transform the request body (if available) to match the format expected from the content type header for "application/json" or "application/x-www-form-urlencoded" + * @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.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?: CustomApiParameters; + headers?: { + authorization?: string; + } & { [key: string]: string }; + body?: BodyInit | unknown; + retrySettings?: OperationOptions; + fetchOptions?: RequestInit; + enableTransformBody?: boolean; + }; + clientConfig: ClientConfig; + 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"; + } + + const clientConfigCopy = { + ...clientConfig, + ...(!clientConfig.baseUri && { baseUri: CUSTOM_API_DEFAULT_BASE_URI }), + parameters: { + ...pathParams, + }, + }; + + // 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 sdkOptions = { + client: { clientConfig: clientConfigCopy }, + path: "/organizations/{organizationId}/{endpointPath}", + pathParameters: pathParams as PathParameters, + queryParameters: optionsCopy.parameters, + headers: optionsCopy.headers, + rawResponse, + retrySettings: (optionsCopy || {})?.retrySettings, + fetchOptions: optionsCopy.fetchOptions, + body: optionsCopy?.body, + disableTransformBody: !optionsCopy?.enableTransformBody, + }; + + const operation = options.method + ? (options.method?.toLowerCase() as + | "delete" + | "get" + | "patch" + | "post" + | "put") + : "get"; + return StaticClient.runFetch(operation, sdkOptions); +}; diff --git a/src/static/helpers/slas.test.ts b/src/static/helpers/slas.test.ts index d0baba95..5245ed7a 100644 --- a/src/static/helpers/slas.test.ts +++ b/src/static/helpers/slas.test.ts @@ -180,7 +180,12 @@ describe("Authorize user", () => { expect(authURL.origin).to.equal(expectedURL.origin); expect(authURL.pathname).to.equal(expectedURL.pathname); - expect(authURL.searchParams).to.deep.equal(expectedURL.searchParams); + + authURL.searchParams.sort(); + expectedURL.searchParams.sort(); + expect(authURL.searchParams.toString()).to.equal( + expectedURL.searchParams.toString() + ); }); it("throws an error when authorization fails", async () => {