From c347cdbd9fa1fdc32035ab57b1757b3ef6ac14e0 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Mon, 15 Apr 2024 16:02:41 -0400 Subject: [PATCH 01/24] add custom endpoint helper --- src/static/helpers/customApi.ts | 75 +++++++++++++++++++++++++++++++++ src/static/helpers/index.ts | 1 + 2 files changed, 76 insertions(+) create mode 100644 src/static/helpers/customApi.ts diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts new file mode 100644 index 0000000..0ebba99 --- /dev/null +++ b/src/static/helpers/customApi.ts @@ -0,0 +1,75 @@ +/* + * 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 TemplateURL from '../templateUrl'; +import type {FetchOptions} from '../clientConfig'; + +// TODO: implement +// export const runFetchHelper = async ( +// url: string, +// options?: { +// [key: string]: any; +// } +// ): Promise => { + +// }; + +// eslint-disable-next-line +export const callCustomEndpoint = async ( + options: { + method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + clientConfig: { + parameters: { + // path parameters + apiName: string; + apiVersion?: string; // default to v1 if not provided + endpointPath: string; + organizationId: string; + shortCode: string; + }; + proxy?: string; + fetchOptions?: FetchOptions; + }; + parameters?: {[key: string]: any}; // query parameters + headers?: { + Authorization: string; + } & {[key: string]: string}; + body?: {[key: string]: any}; + } + // rawResponse?: boolean +): Promise => { + // https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}/organizations/{organizationId}/{endpointPath} + // static readonly defaultBaseUri = "https://{shortCode}.api.commercecloud.salesforce.com/search/shopper-search/{version}/"; + const CUSTOM_BASE_URI = + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; + const CUSTOM_PATH = '/organizations/{organizationId}/{endpointPath}'; + const pathParams = {...options.clientConfig.parameters}; + if (!pathParams.apiVersion) { + pathParams.apiVersion = 'v1'; + } + + const url = new TemplateURL(CUSTOM_PATH, CUSTOM_BASE_URI, { + pathParams, + queryParams: options.parameters, + origin: options.clientConfig.proxy, + }); + + const requestOptions = { + ...options.clientConfig.fetchOptions, + // TODO: this.clientConfig.transformRequest(options.body, headers) + body: options.body as BodyInit, + headers: options.headers, + method: options.method, + }; + + const response = await fetch(url.toString(), requestOptions); + // if (rawResponse) { + // return response; + // } + // const text = await response.text(); + // return text ? JSON.parse(text) : {}; + return response; +}; diff --git a/src/static/helpers/index.ts b/src/static/helpers/index.ts index 452a5cd..4002e1f 100644 --- a/src/static/helpers/index.ts +++ b/src/static/helpers/index.ts @@ -9,3 +9,4 @@ export * from './environment'; export * from './slasHelper'; export * from './types'; +export * from './customApi'; From f640715b4e9fb66a3055f52ade7f0679a39a180c Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Tue, 16 Apr 2024 18:04:45 -0400 Subject: [PATCH 02/24] refactor --- src/static/helpers/customApi.ts | 110 ++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 32 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 0ebba99..d936cd7 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -4,10 +4,18 @@ * 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 {BaseUriParameters} from 'lib/helpers'; import TemplateURL from '../templateUrl'; import type {FetchOptions} from '../clientConfig'; +import ResponseError from '../responseError'; +import {isBrowser, fetch} from './environment'; +import ClientConfig, {ClientConfigInit} from '../clientConfig'; + +// TODO: check if you can pull out version in javascript, that way this doesn't have to be an .hbs file +// import { USER_AGENT_HEADER, USER_AGENT_VALUE } from "../version"; +const USER_AGENT_HEADER = 'user-agent'; +const USER_AGENT_VALUE = 'commerce-sdk-isomorphic@1.13.1'; -// TODO: implement // export const runFetchHelper = async ( // url: string, // options?: { @@ -17,36 +25,52 @@ import type {FetchOptions} from '../clientConfig'; // }; +export interface CustomParams { + apiName: string; + apiVersion?: string; + endpointPath: string; + organizationId: string; + shortCode: string; + [key: string]: any; +} + +// what clientConfig should look like +// clientConfig: { +// proxy?: string, +// fetchOptions?: FetchOptions, +// throwOnBadResponse?: boolean, +// // path parameters +// parameters: { +// apiName: string; +// apiVersion?: string; // default to v1 if not provided +// endpointPath: string; +// organizationId: string; +// shortCode: string; +// }, +// headers?: {[key: string]: string}, +// transformRequest?: NonNullable< +// ClientConfigInit['transformRequest'] +// >; +// }; + // eslint-disable-next-line export const callCustomEndpoint = async ( options: { method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; - clientConfig: { - parameters: { - // path parameters - apiName: string; - apiVersion?: string; // default to v1 if not provided - endpointPath: string; - organizationId: string; - shortCode: string; - }; - proxy?: string; - fetchOptions?: FetchOptions; - }; parameters?: {[key: string]: any}; // query parameters headers?: { - Authorization: string; + authorization: string; } & {[key: string]: string}; body?: {[key: string]: any}; - } - // rawResponse?: boolean -): Promise => { - // https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}/organizations/{organizationId}/{endpointPath} - // static readonly defaultBaseUri = "https://{shortCode}.api.commercecloud.salesforce.com/search/shopper-search/{version}/"; + }, + clientConfig: ClientConfigInit, + rawResponse?: boolean +): Promise => { const CUSTOM_BASE_URI = 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; const CUSTOM_PATH = '/organizations/{organizationId}/{endpointPath}'; - const pathParams = {...options.clientConfig.parameters}; + const pathParams = {...clientConfig.parameters}; + if (!pathParams.apiVersion) { pathParams.apiVersion = 'v1'; } @@ -54,22 +78,44 @@ export const callCustomEndpoint = async ( const url = new TemplateURL(CUSTOM_PATH, CUSTOM_BASE_URI, { pathParams, queryParams: options.parameters, - origin: options.clientConfig.proxy, + origin: clientConfig.proxy, }); - const requestOptions = { - ...options.clientConfig.fetchOptions, - // TODO: this.clientConfig.transformRequest(options.body, headers) - body: options.body as BodyInit, - headers: options.headers, + const headers: Record = { + ...clientConfig?.headers, + ...options?.headers, + }; + + if (!isBrowser) { + // Browsers forbid setting a custom user-agent header + headers[USER_AGENT_HEADER] = [ + headers[USER_AGENT_HEADER], + USER_AGENT_VALUE, + ].join(' '); + } + + const requestOptions: FetchOptions = { + ...clientConfig.fetchOptions, + headers, + // TODO: eventually remove this + // @ts-ignore + body: options.body, + // body: clientConfig.transformRequest(options.body, headers), method: options.method, }; const response = await fetch(url.toString(), requestOptions); - // if (rawResponse) { - // return response; - // } - // const text = await response.text(); - // return text ? JSON.parse(text) : {}; - return response; + if (rawResponse) { + return response; + } + if ( + clientConfig.throwOnBadResponse && + !response.ok && + response.status !== 304 + ) { + throw new ResponseError(response); + } else { + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } }; From 5422ecbe177c276114445409889beb991638b13f Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Tue, 16 Apr 2024 19:01:40 -0400 Subject: [PATCH 03/24] implement runFetchHelper --- src/static/helpers/customApi.ts | 111 ++++++++++++++++---------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index d936cd7..c137988 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -16,70 +16,28 @@ import ClientConfig, {ClientConfigInit} from '../clientConfig'; const USER_AGENT_HEADER = 'user-agent'; const USER_AGENT_VALUE = 'commerce-sdk-isomorphic@1.13.1'; -// export const runFetchHelper = async ( -// url: string, -// options?: { -// [key: string]: any; -// } -// ): Promise => { - -// }; - -export interface CustomParams { - apiName: string; - apiVersion?: string; - endpointPath: string; - organizationId: string; - shortCode: string; - [key: string]: any; -} - -// what clientConfig should look like -// clientConfig: { -// proxy?: string, -// fetchOptions?: FetchOptions, -// throwOnBadResponse?: boolean, -// // path parameters -// parameters: { -// apiName: string; -// apiVersion?: string; // default to v1 if not provided -// endpointPath: string; -// organizationId: string; -// shortCode: string; -// }, -// headers?: {[key: string]: string}, -// transformRequest?: NonNullable< -// ClientConfigInit['transformRequest'] -// >; -// }; - -// eslint-disable-next-line -export const callCustomEndpoint = async ( +export const runFetchHelper = async ( options: { method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; parameters?: {[key: string]: any}; // query parameters + path: string; headers?: { authorization: string; } & {[key: string]: string}; body?: {[key: string]: any}; }, - clientConfig: ClientConfigInit, + clientConfig: ClientConfigInit, // TODO: update Params rawResponse?: boolean -): Promise => { - const CUSTOM_BASE_URI = - 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; - const CUSTOM_PATH = '/organizations/{organizationId}/{endpointPath}'; - const pathParams = {...clientConfig.parameters}; - - if (!pathParams.apiVersion) { - pathParams.apiVersion = 'v1'; - } - - const url = new TemplateURL(CUSTOM_PATH, CUSTOM_BASE_URI, { - pathParams, - queryParams: options.parameters, - origin: clientConfig.proxy, - }); +): Promise => { + const url = new TemplateURL( + options.path, + clientConfig?.baseUri as string, // TODO: potentially make an arg + { + pathParams: clientConfig.parameters, + queryParams: options?.parameters, + origin: clientConfig.proxy, + } + ); const headers: Record = { ...clientConfig?.headers, @@ -116,6 +74,49 @@ export const callCustomEndpoint = async ( 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) : {}; } }; + +export interface CustomParams { + apiName: string; + apiVersion?: string; + endpointPath: string; + organizationId: string; + shortCode: string; + [key: string]: any; +} + +// eslint-disable-next-line +export const callCustomEndpoint = async ( + options: { + method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + parameters?: {[key: string]: any}; // query parameters + headers?: { + authorization: string; + } & {[key: string]: string}; + body?: {[key: string]: any}; + }, + clientConfig: ClientConfigInit, + rawResponse?: boolean +): Promise => { + const CUSTOM_BASE_URI = + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; + const CUSTOM_PATH = '/organizations/{organizationId}/{endpointPath}'; + + const clientConfigCopy = {...clientConfig}; + clientConfigCopy.baseUri = CUSTOM_BASE_URI; + if (!clientConfigCopy.parameters.apiVersion) { + clientConfigCopy.parameters.apiVersion = 'v1'; + } + + return runFetchHelper( + { + ...options, + path: CUSTOM_PATH, + }, + clientConfigCopy, + rawResponse + ); +}; From e14fa989432788cf7ba3feabc519dd9d53fa001b Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Wed, 17 Apr 2024 11:31:35 -0400 Subject: [PATCH 04/24] use runFetchHelper in operations handlebar template --- src/static/helpers/customApi.ts | 12 ++++++++---- templates/client.ts.hbs | 1 + templates/operations.ts.hbs | 34 +++++++++++++++++++++++---------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index c137988..1259e06 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -22,11 +22,13 @@ export const runFetchHelper = async ( parameters?: {[key: string]: any}; // query parameters path: string; headers?: { - authorization: string; + authorization?: string; } & {[key: string]: string}; - body?: {[key: string]: any}; + body?: any, + // body?: {[key: string]: any}; // TODO: fix this }, - clientConfig: ClientConfigInit, // TODO: update Params + clientConfig: any, + // clientConfig: ClientConfigInit, // TODO: update Params rawResponse?: boolean ): Promise => { const url = new TemplateURL( @@ -44,6 +46,8 @@ export const runFetchHelper = async ( ...options?.headers, }; + // TODO: potentially pull this out of helper method + // and leave it in the template if (!isBrowser) { // Browsers forbid setting a custom user-agent header headers[USER_AGENT_HEADER] = [ @@ -72,7 +76,7 @@ export const runFetchHelper = async ( response.status !== 304 ) { throw new ResponseError(response); - } else { + } else { // TODO: figure out how to not respond with anything for void operations 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) : {}; diff --git a/templates/client.ts.hbs b/templates/client.ts.hbs index 4a8ac30..6d0af40 100644 --- a/templates/client.ts.hbs +++ b/templates/client.ts.hbs @@ -1,6 +1,7 @@ import ClientConfig, { ClientConfigInit } from "./clientConfig"; // Must not import from ./helpers/index to avoid circular dependency via ShopperLogin import { isBrowser, fetch } from "./helpers/environment"; +import { runFetchHelper } from "./helpers"; import type { BaseUriParameters, CompositeParameters, diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index f77bc3d..8cd24aa 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -164,7 +164,7 @@ } }) - const url = new TemplateURL( + {{!-- const url = new TemplateURL( "{{{../path}}}", this.clientConfig.baseUri, { @@ -172,9 +172,9 @@ queryParams, origin: this.clientConfig.proxy } - ); + ); --}} - const headers: Record = { + {{!-- const headers: Record = { {{#if (isRequestWithPayload request)}} "Content-Type": "{{{getMediaTypeFromRequest request}}}", {{/if}} @@ -185,27 +185,41 @@ if (!isBrowser) { // Browsers forbid setting a custom user-agent header headers[USER_AGENT_HEADER] = [headers[USER_AGENT_HEADER], USER_AGENT_VALUE].join(" "); - } + } --}} - const requestOptions = { + {{!-- const requestOptions = { ...this.clientConfig.fetchOptions, {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers),{{/if}} headers, method: "{{loud method}}" - }; + }; --}} - const response = await fetch(url.toString(), requestOptions); + return runFetchHelper({ + method: "{{loud method}}", + parameters: queryParams, + path: "{{{../path}}}", + headers: { + {{#if (isRequestWithPayload request)}} + "Content-Type": "{{{getMediaTypeFromRequest request}}}", + {{/if}} + ...options?.headers, + }, + {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, {...options?.headers, ...this.clientConfig?.headers}){{/if}} + }, this.clientConfig, rawResponse) + {{!-- const response = await fetch(url.toString(), requestOptions); if (rawResponse) { return response; } else if (this.clientConfig.throwOnBadResponse && !response.ok && response.status !== 304) { throw new ResponseError(response); - } + } --}} + + // TODO: figure out a way to not return anything on void operations {{#unless (eq (getReturnTypeFromOperation this) "void") }} - else { + {{!-- 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}} } {{/each}} From afe02d3c93676706c3ba051409188e54906fa56f Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Thu, 18 Apr 2024 09:10:46 -0400 Subject: [PATCH 05/24] fix broken calls --- src/static/helpers/customApi.ts | 4 +- templates/operations.ts.hbs | 69 ++++++++++++--------------------- 2 files changed, 28 insertions(+), 45 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 1259e06..2f76888 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -16,6 +16,7 @@ import ClientConfig, {ClientConfigInit} from '../clientConfig'; const USER_AGENT_HEADER = 'user-agent'; const USER_AGENT_VALUE = 'commerce-sdk-isomorphic@1.13.1'; +// TODO: add js/tsdoc comment export const runFetchHelper = async ( options: { method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; @@ -92,13 +93,14 @@ export interface CustomParams { [key: string]: any; } +// TODO: add js/tsdoc comment // eslint-disable-next-line export const callCustomEndpoint = async ( options: { method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; parameters?: {[key: string]: any}; // query parameters headers?: { - authorization: string; + authorization?: string; } & {[key: string]: string}; body?: {[key: string]: any}; }, diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 8cd24aa..654ae42 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -164,17 +164,7 @@ } }) - {{!-- const url = new TemplateURL( - "{{{../path}}}", - this.clientConfig.baseUri, - { - pathParams, - queryParams, - origin: this.clientConfig.proxy - } - ); --}} - - {{!-- const headers: Record = { + const headers: Record = { {{#if (isRequestWithPayload request)}} "Content-Type": "{{{getMediaTypeFromRequest request}}}", {{/if}} @@ -182,45 +172,36 @@ ...options?.headers }; - if (!isBrowser) { + const clientConfigCopy: ClientConfig = { + ...this.clientConfig, + parameters: { + ...this.clientConfig.parameters, + ...pathParams + } + } + {{!-- if (!isBrowser) { // Browsers forbid setting a custom user-agent header headers[USER_AGENT_HEADER] = [headers[USER_AGENT_HEADER], USER_AGENT_VALUE].join(" "); } --}} + const response = await runFetchHelper( + { + method: "{{loud method}}", + parameters: queryParams, + path: "{{{../path}}}", + headers, + {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers){{/if}} + }, + clientConfigCopy, + rawResponse + ) - {{!-- const requestOptions = { - ...this.clientConfig.fetchOptions, - {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers),{{/if}} - headers, - method: "{{loud method}}" - }; --}} - - return runFetchHelper({ - method: "{{loud method}}", - parameters: queryParams, - path: "{{{../path}}}", - headers: { - {{#if (isRequestWithPayload request)}} - "Content-Type": "{{{getMediaTypeFromRequest request}}}", - {{/if}} - ...options?.headers, - }, - {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, {...options?.headers, ...this.clientConfig?.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); - } --}} - - // TODO: figure out a way to not return anything on void operations - {{#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; + {{/if}} } {{/each}} {{/each}} From b570a91411bea6bef8b8233c627d15fb9ed8c69d Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Thu, 18 Apr 2024 11:34:51 -0400 Subject: [PATCH 06/24] fix linting errors and most type errors --- src/static/helpers/customApi.ts | 87 ++++++++++++++++++++------------- templates/operations.ts.hbs | 4 +- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 2f76888..a0473ba 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -4,12 +4,12 @@ * 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 {BaseUriParameters} from 'lib/helpers'; +import {BaseUriParameters, PathParameters} from 'lib/helpers'; import TemplateURL from '../templateUrl'; import type {FetchOptions} from '../clientConfig'; import ResponseError from '../responseError'; import {isBrowser, fetch} from './environment'; -import ClientConfig, {ClientConfigInit} from '../clientConfig'; +import {ClientConfigInit} from '../clientConfig'; // TODO: check if you can pull out version in javascript, that way this doesn't have to be an .hbs file // import { USER_AGENT_HEADER, USER_AGENT_VALUE } from "../version"; @@ -17,30 +17,30 @@ const USER_AGENT_HEADER = 'user-agent'; const USER_AGENT_VALUE = 'commerce-sdk-isomorphic@1.13.1'; // TODO: add js/tsdoc comment -export const runFetchHelper = async ( +export const runFetchHelper = async ( // TODO: also potentially extend { baseUri: string } options: { method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; - parameters?: {[key: string]: any}; // query parameters + parameters?: { + [key: string]: string | number | boolean | string[] | number[]; + }; path: string; headers?: { authorization?: string; } & {[key: string]: string}; - body?: any, - // body?: {[key: string]: any}; // TODO: fix this + // TODO: probably need to fix this type + body?: + | {[key: string]: unknown} + | URLSearchParams + | (BodyInit & (BodyInit | null)); }, - clientConfig: any, - // clientConfig: ClientConfigInit, // TODO: update Params + clientConfig: ClientConfigInit, rawResponse?: boolean -): Promise => { - const url = new TemplateURL( - options.path, - clientConfig?.baseUri as string, // TODO: potentially make an arg - { - pathParams: clientConfig.parameters, - queryParams: options?.parameters, - origin: clientConfig.proxy, - } - ); +): Promise => { + const url = new TemplateURL(options.path, clientConfig.baseUri as string, { + pathParams: clientConfig.parameters as unknown as PathParameters, + queryParams: options?.parameters, + origin: clientConfig.proxy, + }); const headers: Record = { ...clientConfig?.headers, @@ -60,10 +60,8 @@ export const runFetchHelper = async ( const requestOptions: FetchOptions = { ...clientConfig.fetchOptions, headers, - // TODO: eventually remove this - // @ts-ignore - body: options.body, - // body: clientConfig.transformRequest(options.body, headers), + // TODO: probably need to fix this type + body: options.body as unknown as FormData & URLSearchParams, method: options.method, }; @@ -77,10 +75,10 @@ export const runFetchHelper = async ( response.status !== 304 ) { throw new ResponseError(response); - } else { // TODO: figure out how to not respond with anything for void operations + } 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) : {}; + return (text ? JSON.parse(text) : {}) as string | Response; } }; @@ -90,37 +88,56 @@ export interface CustomParams { endpointPath: string; organizationId: string; shortCode: string; - [key: string]: any; + [key: string]: unknown; } // TODO: add js/tsdoc comment -// eslint-disable-next-line export const callCustomEndpoint = async ( options: { method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; - parameters?: {[key: string]: any}; // query parameters + parameters?: { + [key: string]: string | number | boolean | string[] | number[]; + }; headers?: { authorization?: string; } & {[key: string]: string}; - body?: {[key: string]: any}; + // TODO: probably need to fix this type + body?: + | {[key: string]: unknown} + | URLSearchParams + | (BodyInit & (BodyInit | null)); }, clientConfig: ClientConfigInit, rawResponse?: boolean ): Promise => { - const CUSTOM_BASE_URI = - 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; - const CUSTOM_PATH = '/organizations/{organizationId}/{endpointPath}'; + const requiredArgs = [ + 'apiName', + 'endpointPath', + 'organizationId', + 'shortCode', + ]; + requiredArgs.forEach(arg => { + if (!clientConfig.parameters[arg]) { + throw new Error( + `Missing required property in clientConfig.parameters: ${arg}` + ); + } + }); + + const clientConfigCopy: ClientConfigInit = { + ...clientConfig, + baseUri: + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}', + }; - const clientConfigCopy = {...clientConfig}; - clientConfigCopy.baseUri = CUSTOM_BASE_URI; - if (!clientConfigCopy.parameters.apiVersion) { + if (!clientConfigCopy.parameters?.apiVersion) { clientConfigCopy.parameters.apiVersion = 'v1'; } return runFetchHelper( { ...options, - path: CUSTOM_PATH, + path: '/organizations/{organizationId}/{endpointPath}', }, clientConfigCopy, rawResponse diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 654ae42..2733034 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -197,10 +197,10 @@ {{#if (eq (getReturnTypeFromOperation this) "void") }} if (rawResponse) { - return response; + return response as Response; } {{else}} - return response; + return response as Response | {{getReturnTypeFromOperation this}}; {{/if}} } {{/each}} From fef40ae296e0774b558f58dedddaa8d248f4c7fc Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Thu, 18 Apr 2024 16:22:16 -0400 Subject: [PATCH 07/24] Refactor and add tsdoc comments --- src/static/helpers/customApi.ts | 101 +++++++++++++++++--------------- templates/operations.ts.hbs | 27 +++++---- 2 files changed, 69 insertions(+), 59 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index a0473ba..df5132c 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -8,22 +8,27 @@ import {BaseUriParameters, PathParameters} from 'lib/helpers'; import TemplateURL from '../templateUrl'; import type {FetchOptions} from '../clientConfig'; import ResponseError from '../responseError'; -import {isBrowser, fetch} from './environment'; +import {fetch} from './environment'; import {ClientConfigInit} from '../clientConfig'; -// TODO: check if you can pull out version in javascript, that way this doesn't have to be an .hbs file -// import { USER_AGENT_HEADER, USER_AGENT_VALUE } from "../version"; -const USER_AGENT_HEADER = 'user-agent'; -const USER_AGENT_VALUE = 'commerce-sdk-isomorphic@1.13.1'; - -// TODO: add js/tsdoc comment -export const runFetchHelper = async ( // TODO: also potentially extend { baseUri: string } - options: { - method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; - parameters?: { - [key: string]: string | number | boolean | string[] | number[]; - }; - path: string; +/** + * 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. The available options are: 'GET', 'POST', 'PATCH', 'PUT', and 'DELETE'. '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 + */ +export const runFetchHelper = async ( + url: string, + options?: { + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; headers?: { authorization?: string; } & {[key: string]: string}; @@ -33,44 +38,28 @@ export const runFetchHelper = async ( // TODO: | URLSearchParams | (BodyInit & (BodyInit | null)); }, - clientConfig: ClientConfigInit, + clientConfig?: ClientConfigInit, rawResponse?: boolean -): Promise => { - const url = new TemplateURL(options.path, clientConfig.baseUri as string, { - pathParams: clientConfig.parameters as unknown as PathParameters, - queryParams: options?.parameters, - origin: clientConfig.proxy, - }); - +): Promise => { const headers: Record = { ...clientConfig?.headers, ...options?.headers, }; - // TODO: potentially pull this out of helper method - // and leave it in the template - if (!isBrowser) { - // Browsers forbid setting a custom user-agent header - headers[USER_AGENT_HEADER] = [ - headers[USER_AGENT_HEADER], - USER_AGENT_VALUE, - ].join(' '); - } - const requestOptions: FetchOptions = { - ...clientConfig.fetchOptions, + ...clientConfig?.fetchOptions, headers, // TODO: probably need to fix this type - body: options.body as unknown as FormData & URLSearchParams, - method: options.method, + body: options?.body as unknown as FormData & URLSearchParams, + method: options?.method ?? 'GET', }; - const response = await fetch(url.toString(), requestOptions); + const response = await fetch(url, requestOptions); if (rawResponse) { return response; } if ( - clientConfig.throwOnBadResponse && + clientConfig?.throwOnBadResponse && !response.ok && response.status !== 304 ) { @@ -78,7 +67,7 @@ export const runFetchHelper = async ( // TODO: } 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 string | Response; + return (text ? JSON.parse(text) : {}) as unknown | Response; } }; @@ -91,10 +80,25 @@ export interface CustomParams { [key: string]: unknown; } -// TODO: add js/tsdoc comment +/** + * 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?q=custom+API) + * @param options - An object containing any custom settings you want to apply to the request + * @param options.method? - The request HTTP operation. The available options are: 'GET', 'POST', 'PATCH', 'PUT', and 'DELETE'. 'GET' is the default if no method is provided. + * @param options.parameters? - Query parameters that are added to the request + * @param options.headers? - Headers that are added to the request. Authorization header should be in this parameter 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.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 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 + */ export const callCustomEndpoint = async ( options: { - method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; parameters?: { [key: string]: string | number | boolean | string[] | number[]; }; @@ -109,7 +113,7 @@ export const callCustomEndpoint = async ( }, clientConfig: ClientConfigInit, rawResponse?: boolean -): Promise => { +): Promise => { const requiredArgs = [ 'apiName', 'endpointPath', @@ -134,12 +138,15 @@ export const callCustomEndpoint = async ( clientConfigCopy.parameters.apiVersion = 'v1'; } - return runFetchHelper( + const url = new TemplateURL( + '/organizations/{organizationId}/{endpointPath}', + clientConfigCopy.baseUri as string, { - ...options, - path: '/organizations/{organizationId}/{endpointPath}', - }, - clientConfigCopy, - rawResponse + pathParams: clientConfigCopy.parameters as unknown as PathParameters, + queryParams: options.parameters, + origin: clientConfig.proxy, + } ); + + return runFetchHelper(url.toString(), options, clientConfigCopy, rawResponse); }; diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 2733034..691d5ad 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -164,6 +164,16 @@ } }) + const url = new TemplateURL( + "{{{../path}}}", + this.clientConfig.baseUri, + { + pathParams, + queryParams, + origin: this.clientConfig.proxy + } + ); + const headers: Record = { {{#if (isRequestWithPayload request)}} "Content-Type": "{{{getMediaTypeFromRequest request}}}", @@ -172,26 +182,19 @@ ...options?.headers }; - const clientConfigCopy: ClientConfig = { - ...this.clientConfig, - parameters: { - ...this.clientConfig.parameters, - ...pathParams - } - } - {{!-- if (!isBrowser) { + if (!isBrowser) { // Browsers forbid setting a custom user-agent header headers[USER_AGENT_HEADER] = [headers[USER_AGENT_HEADER], USER_AGENT_VALUE].join(" "); - } --}} + } + const response = await runFetchHelper( + url.toString(), { method: "{{loud method}}", - parameters: queryParams, - path: "{{{../path}}}", headers, {{#if (isRequestWithPayload request)}}body: this.clientConfig.transformRequest(options.body, headers){{/if}} }, - clientConfigCopy, + this.clientConfig, rawResponse ) From 04e4ae1579132d1e3266d147ad733638c7e0831e Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Fri, 19 Apr 2024 11:30:08 -0400 Subject: [PATCH 08/24] refactor and add unit test --- src/static/helpers/customApi.test.ts | 129 +++++++++++++++++++++++++++ src/static/helpers/customApi.ts | 72 ++------------- src/static/helpers/fetchHelper.ts | 73 +++++++++++++++ src/static/helpers/index.ts | 1 + templates/client.ts.hbs | 3 +- 5 files changed, 209 insertions(+), 69 deletions(-) create mode 100644 src/static/helpers/customApi.test.ts create mode 100644 src/static/helpers/fetchHelper.ts diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts new file mode 100644 index 0000000..e160f5d --- /dev/null +++ b/src/static/helpers/customApi.test.ts @@ -0,0 +1,129 @@ +/* + * 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} from './customApi'; +import * as fetchHelper from './fetchHelper'; +import ClientConfig from '../clientConfig'; +import {CustomParams} from '../../lib/helpers'; + +describe('callCustomEndpoint', () => { + beforeEach(() => { + jest.restoreAllMocks(); + nock.cleanAll(); + }); + + const clientConfigParameters: CustomParams = { + shortCode: 'short_code', + organizationId: 'organization_id', + clientId: 'client_id', + siteId: 'site_id', + apiName: 'api_name', + apiVersion: 'v2', + endpointPath: 'endpoint_path', + }; + + const options = { + method: 'POST', + parameters: { + queryParam1: 'query parameter 1', + queryParam2: 'query parameter 2', + }, + headers: { + authorization: 'Bearer token', + }, + body: { + data: 'data', + }, + }; + + test('throws an error when required path parameters are not passed', () => { + // separate apiName using spread since we can't use 'delete' operator as it isn't marked as optional + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {apiName, ...copyClientConfigParams} = clientConfigParameters; + + const clientConfig = new ClientConfig({ + parameters: copyClientConfigParams, + }); + + const asyncFuncCall = async (): Promise => { + // eslint-disable-next-line + // @ts-ignore + await callCustomEndpoint(options, clientConfig); + }; + + expect(asyncFuncCall) + .rejects.toThrow( + 'Missing required property in clientConfig.parameters: apiName' + ) + .finally(() => 'resolve promise'); + }); + + test('sets api version to "v1" if not provided', async () => { + const copyClientConfigParams = {...clientConfigParameters}; + delete copyClientConfigParams.apiVersion; + + const clientConfig = new ClientConfig({ + parameters: copyClientConfigParams, + }); + + const {shortCode, apiName, organizationId, endpointPath} = + clientConfig.parameters; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/v1/organizations/${organizationId}/${endpointPath}`; + nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); + + const expectedUrl = `${ + nockBasePath + nockEndpointPath + }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; + const runFetchHelperSpy = jest.spyOn(fetchHelper, 'runFetchHelper'); + + await callCustomEndpoint(options, clientConfig); + + expect(runFetchHelperSpy).toBeCalledTimes(1); + expect(runFetchHelperSpy).toBeCalledWith( + expectedUrl, + options, + expect.anything(), + undefined + ); + expect(expectedUrl).toContain('/v1/'); + }); + + test('runFetchHelper is called with the correct arguments', async () => { + const clientConfig = new ClientConfig({ + parameters: clientConfigParameters, + }); + + const {shortCode, apiName, organizationId, endpointPath} = + clientConfig.parameters; + + const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`; + const nockEndpointPath = `/custom/${apiName}/v2/organizations/${organizationId}/${endpointPath}`; + nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); + + const expectedUrl = `${ + nockBasePath + nockEndpointPath + }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; + const expectedClientConfig = { + ...clientConfig, + baseUri: + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}', + }; + + const runFetchHelperSpy = jest.spyOn(fetchHelper, 'runFetchHelper'); + await callCustomEndpoint(options, clientConfig, true); + expect(runFetchHelperSpy).toBeCalledTimes(1); + expect(runFetchHelperSpy).toBeCalledWith( + expectedUrl, + options, + expectedClientConfig, + true + ); + }); +}); diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index df5132c..6763220 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -1,76 +1,14 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * 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 {BaseUriParameters, PathParameters} from 'lib/helpers'; +import {PathParameters} from './types'; +import {runFetchHelper} from './fetchHelper'; import TemplateURL from '../templateUrl'; -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. The available options are: 'GET', 'POST', 'PATCH', 'PUT', and 'DELETE'. '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 - */ -export const runFetchHelper = async ( - url: string, - options?: { - method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; - headers?: { - authorization?: string; - } & {[key: string]: string}; - // TODO: probably need to fix this type - body?: - | {[key: string]: unknown} - | URLSearchParams - | (BodyInit & (BodyInit | null)); - }, - clientConfig?: ClientConfigInit, - rawResponse?: boolean -): Promise => { - const headers: Record = { - ...clientConfig?.headers, - ...options?.headers, - }; - - const requestOptions: FetchOptions = { - ...clientConfig?.fetchOptions, - headers, - // TODO: probably need to fix this type - body: options?.body as unknown as FormData & URLSearchParams, - 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; - } -}; - export interface CustomParams { apiName: string; apiVersion?: string; @@ -84,7 +22,7 @@ export interface CustomParams { * 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?q=custom+API) * @param options - An object containing any custom settings you want to apply to the request - * @param options.method? - The request HTTP operation. The available options are: 'GET', 'POST', 'PATCH', 'PUT', and 'DELETE'. 'GET' is the default if no method is provided. + * @param options.method? - The request HTTP operation. 'GET' is the default if no method is provided. * @param options.parameters? - Query parameters that are added to the request * @param options.headers? - Headers that are added to the request. Authorization header should be in this parameter or in the clientConfig.headers * @param options.body? - Body that is used for the request @@ -98,7 +36,7 @@ export interface CustomParams { */ export const callCustomEndpoint = async ( options: { - method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + method?: string; parameters?: { [key: string]: string | number | boolean | string[] | number[]; }; diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts new file mode 100644 index 0000000..4c8db1d --- /dev/null +++ b/src/static/helpers/fetchHelper.ts @@ -0,0 +1,73 @@ +/* + * 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 {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 runFetchHelper = async ( + url: string, + options?: { + method?: string; + headers?: { + authorization?: string; + } & {[key: string]: string}; + // TODO: probably need to fix this type + body?: + | {[key: string]: unknown} + | URLSearchParams + | (BodyInit & (BodyInit | null)); + }, + clientConfig?: ClientConfigInit, + rawResponse?: boolean +): Promise => { + const headers: Record = { + ...clientConfig?.headers, + ...options?.headers, + }; + + const requestOptions: FetchOptions = { + ...clientConfig?.fetchOptions, + headers, + // TODO: probably need to fix this type + ...(options?.body && + ({body: options.body} as unknown as FormData & URLSearchParams)), + 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 4002e1f..f72986b 100644 --- a/src/static/helpers/index.ts +++ b/src/static/helpers/index.ts @@ -10,3 +10,4 @@ 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 6d0af40..4aa6460 100644 --- a/templates/client.ts.hbs +++ b/templates/client.ts.hbs @@ -1,13 +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 { runFetchHelper } 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"; From dd26eae161cc0e6fc06cfbc3388da5dbb62e290c Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Fri, 19 Apr 2024 11:42:15 -0400 Subject: [PATCH 09/24] add comment for test coverage --- src/static/helpers/environment.ts | 2 ++ 1 file changed, 2 insertions(+) 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' From 55afa1d8c47f27e2cae77d97404defd0d87abae0 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Fri, 19 Apr 2024 16:47:28 -0400 Subject: [PATCH 10/24] add unit test and update changelog --- CHANGELOG.md | 6 ++ src/static/helpers/customApi.test.ts | 8 +- src/static/helpers/fetchHelper.test.ts | 116 +++++++++++++++++++++++++ src/static/helpers/fetchHelper.ts | 3 +- 4 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 src/static/helpers/fetchHelper.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f608132..540e078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## v1.14.0-dev + +#### Enchancements + +- Add support for custom APIs [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149) + ## v1.13.1 #### Bug fixes diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index e160f5d..073b9bb 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -50,13 +50,11 @@ describe('callCustomEndpoint', () => { parameters: copyClientConfigParams, }); - const asyncFuncCall = async (): Promise => { + expect(async () => { // eslint-disable-next-line - // @ts-ignore + // @ts-ignore <-- we know it'll complain since we removed apiName await callCustomEndpoint(options, clientConfig); - }; - - expect(asyncFuncCall) + }) .rejects.toThrow( 'Missing required property in clientConfig.parameters: apiName' ) diff --git a/src/static/helpers/fetchHelper.test.ts b/src/static/helpers/fetchHelper.test.ts new file mode 100644 index 0000000..869328c --- /dev/null +++ b/src/static/helpers/fetchHelper.test.ts @@ -0,0 +1,116 @@ +/* + * 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 {runFetchHelper} from './fetchHelper'; + +describe('runFetchHelper', () => { + 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', + }, + headers: { + clientConfigHeader: 'clientConfigHeader', + repeatHeader: 'clientConfig.headers', + }, + fetchOptions: { + cache: 'no-cache', + }, + }); + + const options = { + method: 'POST', + headers: { + authorization: 'Bearer token', + repeatHeader: 'options.headers', + }, + body: { + data: 'data', + }, + }; + + const responseBody = {message: 'request has matched'}; + + beforeEach(() => { + jest.restoreAllMocks(); + nock.cleanAll(); + }); + + test('uses headers from both clientConfig and headers object', async () => { + nock(basePath, { + reqheaders: { + authorization: 'Bearer token', + repeatHeader: 'options.headers', // options header takes priority + clientConfigHeader: 'clientConfigHeader', + }, + }) + .post(endpointPath) + .query({siteId: 'site_id'}) + .reply(200, responseBody); + + const response = await runFetchHelper(url, options, clientConfig); + expect(response).toEqual(responseBody); + }); + + 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 runFetchHelper( + 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 runFetchHelper(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 runFetchHelper(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 runFetchHelper(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 index 4c8db1d..4bdad9b 100644 --- a/src/static/helpers/fetchHelper.ts +++ b/src/static/helpers/fetchHelper.ts @@ -50,8 +50,7 @@ export const runFetchHelper = async ( ...clientConfig?.fetchOptions, headers, // TODO: probably need to fix this type - ...(options?.body && - ({body: options.body} as unknown as FormData & URLSearchParams)), + body: options?.body as unknown as FormData & URLSearchParams, method: options?.method ?? 'GET', }; From 2ee3c0e2ca32a7620f5f69a72777af2d0b7d5fcc Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Mon, 22 Apr 2024 12:08:35 -0400 Subject: [PATCH 11/24] update type for body --- src/static/helpers/customApi.ts | 6 ++---- src/static/helpers/fetchHelper.ts | 10 +++------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 6763220..51b7c84 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -4,6 +4,7 @@ * 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 {runFetchHelper} from './fetchHelper'; import TemplateURL from '../templateUrl'; @@ -44,10 +45,7 @@ export const callCustomEndpoint = async ( authorization?: string; } & {[key: string]: string}; // TODO: probably need to fix this type - body?: - | {[key: string]: unknown} - | URLSearchParams - | (BodyInit & (BodyInit | null)); + body?: BodyInit | unknown; }, clientConfig: ClientConfigInit, rawResponse?: boolean diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts index 4bdad9b..26ef742 100644 --- a/src/static/helpers/fetchHelper.ts +++ b/src/static/helpers/fetchHelper.ts @@ -4,6 +4,7 @@ * 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'; @@ -32,11 +33,7 @@ export const runFetchHelper = async ( headers?: { authorization?: string; } & {[key: string]: string}; - // TODO: probably need to fix this type - body?: - | {[key: string]: unknown} - | URLSearchParams - | (BodyInit & (BodyInit | null)); + body?: BodyInit | unknown; }, clientConfig?: ClientConfigInit, rawResponse?: boolean @@ -49,8 +46,7 @@ export const runFetchHelper = async ( const requestOptions: FetchOptions = { ...clientConfig?.fetchOptions, headers, - // TODO: probably need to fix this type - body: options?.body as unknown as FormData & URLSearchParams, + body: options?.body as BodyInit & string, method: options?.method ?? 'GET', }; From b646330bab6183ef05cbd3949077050f60f9c8dd Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Tue, 23 Apr 2024 10:10:56 -0400 Subject: [PATCH 12/24] address PR comments --- CHANGELOG.md | 2 +- src/static/helpers/customApi.test.ts | 14 +++--- src/static/helpers/customApi.ts | 4 +- src/static/helpers/fetchHelper.test.ts | 59 ++++++++++++++++++-------- src/static/helpers/fetchHelper.ts | 2 +- templates/client.ts.hbs | 2 +- templates/operations.ts.hbs | 2 +- 7 files changed, 55 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 540e078..f3a74f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ #### Enchancements -- Add support for custom APIs [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149) +- Add support for custom APIs with `callCustomEndpoint` helper function [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149) ## v1.13.1 diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index 073b9bb..8d5f18d 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -79,12 +79,12 @@ describe('callCustomEndpoint', () => { const expectedUrl = `${ nockBasePath + nockEndpointPath }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; - const runFetchHelperSpy = jest.spyOn(fetchHelper, 'runFetchHelper'); + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); await callCustomEndpoint(options, clientConfig); - expect(runFetchHelperSpy).toBeCalledTimes(1); - expect(runFetchHelperSpy).toBeCalledWith( + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( expectedUrl, options, expect.anything(), @@ -93,7 +93,7 @@ describe('callCustomEndpoint', () => { expect(expectedUrl).toContain('/v1/'); }); - test('runFetchHelper is called with the correct arguments', async () => { + test('doFetch is called with the correct arguments', async () => { const clientConfig = new ClientConfig({ parameters: clientConfigParameters, }); @@ -114,10 +114,10 @@ describe('callCustomEndpoint', () => { 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}', }; - const runFetchHelperSpy = jest.spyOn(fetchHelper, 'runFetchHelper'); + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); await callCustomEndpoint(options, clientConfig, true); - expect(runFetchHelperSpy).toBeCalledTimes(1); - expect(runFetchHelperSpy).toBeCalledWith( + expect(doFetchSpy).toBeCalledTimes(1); + expect(doFetchSpy).toBeCalledWith( expectedUrl, options, expectedClientConfig, diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 51b7c84..a1771c7 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -6,7 +6,7 @@ */ import {BodyInit} from 'node-fetch'; import {PathParameters} from './types'; -import {runFetchHelper} from './fetchHelper'; +import {doFetch} from './fetchHelper'; import TemplateURL from '../templateUrl'; import {ClientConfigInit} from '../clientConfig'; @@ -84,5 +84,5 @@ export const callCustomEndpoint = async ( } ); - return runFetchHelper(url.toString(), options, clientConfigCopy, rawResponse); + return doFetch(url.toString(), options, clientConfigCopy, rawResponse); }; diff --git a/src/static/helpers/fetchHelper.test.ts b/src/static/helpers/fetchHelper.test.ts index 869328c..f88a07d 100644 --- a/src/static/helpers/fetchHelper.test.ts +++ b/src/static/helpers/fetchHelper.test.ts @@ -8,9 +8,9 @@ import nock from 'nock'; import {Response} from 'node-fetch'; import * as environment from './environment'; import ClientConfig from '../clientConfig'; -import {runFetchHelper} from './fetchHelper'; +import {doFetch} from './fetchHelper'; -describe('runFetchHelper', () => { +describe('doFetch', () => { const basePath = 'https://short_code.api.commercecloud.salesforce.com'; const endpointPath = '/checkout/shopper-baskets/v1/organizations/organization_id/baskets'; @@ -23,10 +23,6 @@ describe('runFetchHelper', () => { clientId: 'client_id', siteId: 'site_id', }, - headers: { - clientConfigHeader: 'clientConfigHeader', - repeatHeader: 'clientConfig.headers', - }, fetchOptions: { cache: 'no-cache', }, @@ -36,7 +32,6 @@ describe('runFetchHelper', () => { method: 'POST', headers: { authorization: 'Bearer token', - repeatHeader: 'options.headers', }, body: { data: 'data', @@ -51,19 +46,49 @@ describe('runFetchHelper', () => { }); test('uses headers from both clientConfig and headers object', async () => { - nock(basePath, { - reqheaders: { - authorization: 'Bearer token', - repeatHeader: 'options.headers', // options header takes priority - clientConfigHeader: 'clientConfigHeader', + 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 response = await runFetchHelper(url, options, clientConfig); + 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 () => { @@ -72,7 +97,7 @@ describe('runFetchHelper', () => { .query({siteId: 'site_id'}) .reply(200, responseBody); - const response = (await runFetchHelper( + const response = (await doFetch( url, options, clientConfig, @@ -89,7 +114,7 @@ describe('runFetchHelper', () => { const copyClientConfig = {...clientConfig, throwOnBadResponse: true}; expect(async () => { - await runFetchHelper(url, options, copyClientConfig); + await doFetch(url, options, copyClientConfig); }) .rejects.toThrow('400 Bad Request') .finally(() => 'resolve promise'); @@ -98,7 +123,7 @@ describe('runFetchHelper', () => { 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 runFetchHelper(url, options, clientConfig, false); + const data = await doFetch(url, options, clientConfig, false); expect(data).toEqual(responseBody); }); @@ -106,7 +131,7 @@ describe('runFetchHelper', () => { nock(basePath).post(endpointPath).query(true).reply(200, responseBody); const spy = jest.spyOn(environment, 'fetch'); - await runFetchHelper(url, options, clientConfig, false); + await doFetch(url, options, clientConfig, false); expect(spy).toBeCalledTimes(1); expect(spy).toBeCalledWith( expect.any(String), diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts index 26ef742..9036328 100644 --- a/src/static/helpers/fetchHelper.ts +++ b/src/static/helpers/fetchHelper.ts @@ -26,7 +26,7 @@ import {ClientConfigInit} from '../clientConfig'; * @returns Raw response or data from response based on rawResponse argument from fetch call */ // eslint-disable-next-line import/prefer-default-export -export const runFetchHelper = async ( +export const doFetch = async ( url: string, options?: { method?: string; diff --git a/templates/client.ts.hbs b/templates/client.ts.hbs index 4aa6460..9821da1 100644 --- a/templates/client.ts.hbs +++ b/templates/client.ts.hbs @@ -1,7 +1,7 @@ import ClientConfig, { ClientConfigInit } from "./clientConfig"; // Must not import from ./helpers/index to avoid circular dependency via ShopperLogin import { isBrowser } from "./helpers/environment"; -import { runFetchHelper } from "./helpers"; +import { doFetch } from "./helpers"; import type { BaseUriParameters, CompositeParameters, diff --git a/templates/operations.ts.hbs b/templates/operations.ts.hbs index 691d5ad..0961226 100644 --- a/templates/operations.ts.hbs +++ b/templates/operations.ts.hbs @@ -187,7 +187,7 @@ headers[USER_AGENT_HEADER] = [headers[USER_AGENT_HEADER], USER_AGENT_VALUE].join(" "); } - const response = await runFetchHelper( + const response = await doFetch( url.toString(), { method: "{{loud method}}", From 48dcd28cc25d5cf6343d515d7cc0dfa602c9dc0d Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Tue, 23 Apr 2024 10:28:16 -0400 Subject: [PATCH 13/24] add example in README --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index b2da524..068b7e4 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,62 @@ 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 with a helper function, `callCustomEndpoint`. + +Example usage: + +```javascript +import pkg from 'commerce-sdk-isomorphic'; +const { helpers } = pkg; + +const CLIENT_ID = ""; +const ORG_ID = ""; +const SHORT_CODE = ""; +const SITE_ID = ""; + +// client configuration parameters +const clientConfig = { + parameters: { + clientId: CLIENT_ID, + organizationId: ORG_ID, + shortCode: SHORT_CODE, + siteId: SITE_ID, + // Custom API path parameters + endpointPath: 'customers', + apiName: 'loyalty-info', + apiVersion: 'v1', // defaults to v1 if not provided + }, +}; + +// Flag to retrieve raw response or data from helper function +const rawResponse = false; +const accessToken = ''; + +let response = await helpers.callCustomEndpoint( + { + method: 'GET', + parameters: { + queryParameter: 'queryParameter1', + siteId: SITE_ID, + }, + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${access_token}` + } + }, + clientConfig, + rawResponse +) + +console.log('RESPONSE: ', response) +``` + +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?q=custom+API) + ## License Information The Commerce SDK Isomorphic is licensed under BSD-3-Clause license. See the [license](./LICENSE.txt) for details. From ef6033a890ead1da27e937b8958224563a1afc12 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Wed, 24 Apr 2024 09:04:24 -0400 Subject: [PATCH 14/24] update types and allow baseUri as argument --- src/static/helpers/customApi.ts | 29 +++++++++++++++++------------ src/static/helpers/fetchHelper.ts | 4 ++-- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index a1771c7..e4b2886 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -30,8 +30,10 @@ export interface CustomParams { * @param clientConfig - Client Configuration object used by the SDK with properties that can affect the fetch call * @param 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 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.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 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 clientConfig.proxy? - Routes API calls through a proxy when set * @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 */ @@ -44,8 +46,7 @@ export const callCustomEndpoint = async ( headers?: { authorization?: string; } & {[key: string]: string}; - // TODO: probably need to fix this type - body?: BodyInit | unknown; + body?: BodyInit | globalThis.BodyInit | unknown; }, clientConfig: ClientConfigInit, rawResponse?: boolean @@ -64,23 +65,27 @@ export const callCustomEndpoint = async ( } }); - const clientConfigCopy: ClientConfigInit = { - ...clientConfig, - baseUri: - 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}', - }; - - if (!clientConfigCopy.parameters?.apiVersion) { - clientConfigCopy.parameters.apiVersion = 'v1'; + const defaultBaseUri = 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}' + let clientConfigCopy = clientConfig + + if(!clientConfig.baseUri || !clientConfig.parameters?.apiVersion) { + clientConfigCopy = { + ...clientConfig, + ...(!clientConfig.baseUri && { baseUri: defaultBaseUri }), + parameters: { + ...clientConfig.parameters, + ...(!clientConfig.parameters?.apiVersion && { apiVersion: 'v1' }) + } + }; } const url = new TemplateURL( '/organizations/{organizationId}/{endpointPath}', clientConfigCopy.baseUri as string, { - pathParams: clientConfigCopy.parameters as unknown as PathParameters, + pathParams: clientConfigCopy.parameters as PathParameters, queryParams: options.parameters, - origin: clientConfig.proxy, + origin: clientConfigCopy.proxy, } ); diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts index 9036328..538665e 100644 --- a/src/static/helpers/fetchHelper.ts +++ b/src/static/helpers/fetchHelper.ts @@ -33,7 +33,7 @@ export const doFetch = async ( headers?: { authorization?: string; } & {[key: string]: string}; - body?: BodyInit | unknown; + body?: BodyInit | globalThis.BodyInit | unknown; }, clientConfig?: ClientConfigInit, rawResponse?: boolean @@ -46,7 +46,7 @@ export const doFetch = async ( const requestOptions: FetchOptions = { ...clientConfig?.fetchOptions, headers, - body: options?.body as BodyInit & string, + body: options?.body as (BodyInit & (globalThis.BodyInit | null)) | undefined, method: options?.method ?? 'GET', }; From d73dfabe654ada1dc5e0b57a4df8e7f42b313ca0 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Wed, 24 Apr 2024 09:26:15 -0400 Subject: [PATCH 15/24] lint --- src/static/helpers/customApi.ts | 15 ++++++++------- src/static/helpers/fetchHelper.ts | 4 +++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index e4b2886..a9f9edc 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -65,17 +65,18 @@ export const callCustomEndpoint = async ( } }); - const defaultBaseUri = 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}' - let clientConfigCopy = clientConfig - - if(!clientConfig.baseUri || !clientConfig.parameters?.apiVersion) { + const defaultBaseUri = + 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; + let clientConfigCopy = clientConfig; + + if (!clientConfig.baseUri || !clientConfig.parameters?.apiVersion) { clientConfigCopy = { ...clientConfig, - ...(!clientConfig.baseUri && { baseUri: defaultBaseUri }), + ...(!clientConfig.baseUri && {baseUri: defaultBaseUri}), parameters: { ...clientConfig.parameters, - ...(!clientConfig.parameters?.apiVersion && { apiVersion: 'v1' }) - } + ...(!clientConfig.parameters?.apiVersion && {apiVersion: 'v1'}), + }, }; } diff --git a/src/static/helpers/fetchHelper.ts b/src/static/helpers/fetchHelper.ts index 538665e..7b9cbd9 100644 --- a/src/static/helpers/fetchHelper.ts +++ b/src/static/helpers/fetchHelper.ts @@ -46,7 +46,9 @@ export const doFetch = async ( const requestOptions: FetchOptions = { ...clientConfig?.fetchOptions, headers, - body: options?.body as (BodyInit & (globalThis.BodyInit | null)) | undefined, + body: options?.body as + | (BodyInit & (globalThis.BodyInit | null)) + | undefined, method: options?.method ?? 'GET', }; From 237e7cc5567bd0046bb6eb0975832e9c24e1d7c4 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Wed, 24 Apr 2024 13:44:40 -0400 Subject: [PATCH 16/24] add check in test for response status code --- src/static/helpers/customApi.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index 8d5f18d..f0f3c54 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -81,14 +81,19 @@ describe('callCustomEndpoint', () => { }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); - await callCustomEndpoint(options, clientConfig); + const response = (await callCustomEndpoint( + options, + clientConfig, + true + )) as Response; + expect(response.status).toBe(200); expect(doFetchSpy).toBeCalledTimes(1); expect(doFetchSpy).toBeCalledWith( expectedUrl, options, expect.anything(), - undefined + true ); expect(expectedUrl).toContain('/v1/'); }); From f7ab768c894ca88768001c46f0e617510ff99ae3 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Wed, 24 Apr 2024 19:00:51 -0400 Subject: [PATCH 17/24] combine params into 1 object and pull out custom path params into options --- src/static/helpers/customApi.test.ts | 144 +++++++++++++++++++-------- src/static/helpers/customApi.ts | 79 +++++++++------ 2 files changed, 149 insertions(+), 74 deletions(-) diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index f0f3c54..db000de 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -6,10 +6,9 @@ */ import nock from 'nock'; -import {callCustomEndpoint} from './customApi'; +import {callCustomEndpoint, CustomParams} from './customApi'; import * as fetchHelper from './fetchHelper'; import ClientConfig from '../clientConfig'; -import {CustomParams} from '../../lib/helpers'; describe('callCustomEndpoint', () => { beforeEach(() => { @@ -17,15 +16,14 @@ describe('callCustomEndpoint', () => { nock.cleanAll(); }); - const clientConfigParameters: CustomParams = { - shortCode: 'short_code', - organizationId: 'organization_id', - clientId: 'client_id', - siteId: 'site_id', - apiName: 'api_name', - apiVersion: 'v2', - endpointPath: 'endpoint_path', - }; + const clientConfig = new ClientConfig({ + parameters: { + shortCode: 'short_code', + organizationId: 'organization_id', + clientId: 'client_id', + siteId: 'site_id', + }, + }); const options = { method: 'POST', @@ -33,6 +31,11 @@ describe('callCustomEndpoint', () => { queryParam1: 'query parameter 1', queryParam2: 'query parameter 2', }, + customApiPathParameters: { + apiName: 'api_name', + apiVersion: 'v2', + endpointPath: 'endpoint_path', + }, headers: { authorization: 'Bearer token', }, @@ -42,38 +45,42 @@ describe('callCustomEndpoint', () => { }; test('throws an error when required path parameters are not passed', () => { - // separate apiName using spread since we can't use 'delete' operator as it isn't marked as optional - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {apiName, ...copyClientConfigParams} = clientConfigParameters; - - const clientConfig = new ClientConfig({ - parameters: copyClientConfigParams, - }); + 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 apiName - await callCustomEndpoint(options, clientConfig); + // @ts-ignore <-- we know it'll complain since we removed endpointPath + await callCustomEndpoint({options: copyOptions, clientConfig}); }) .rejects.toThrow( - 'Missing required property in clientConfig.parameters: apiName' + 'Missing required property needed in args.options.customApiPathParameters or args.clientConfig.parameters: endpointPath' ) .finally(() => 'resolve promise'); }); test('sets api version to "v1" if not provided', async () => { - const copyClientConfigParams = {...clientConfigParameters}; - delete copyClientConfigParams.apiVersion; - - const clientConfig = new ClientConfig({ - parameters: copyClientConfigParams, - }); + const copyOptions = { + ...options, + // omit apiVersion + customApiPathParameters: { + endpointPath: 'endpoint_path', + apiName: 'api_name', + }, + }; - const {shortCode, apiName, organizationId, endpointPath} = - clientConfig.parameters; + 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}/${endpointPath}`; + const nockEndpointPath = `/custom/${apiName}/v1/organizations/${ + organizationId as string + }/${endpointPath}`; nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); const expectedUrl = `${ @@ -81,17 +88,17 @@ describe('callCustomEndpoint', () => { }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); - const response = (await callCustomEndpoint( - options, + const response = (await callCustomEndpoint({ + options: copyOptions, clientConfig, - true - )) as Response; + rawResponse: true, + })) as Response; expect(response.status).toBe(200); expect(doFetchSpy).toBeCalledTimes(1); expect(doFetchSpy).toBeCalledWith( expectedUrl, - options, + copyOptions, expect.anything(), true ); @@ -99,15 +106,13 @@ describe('callCustomEndpoint', () => { }); test('doFetch is called with the correct arguments', async () => { - const clientConfig = new ClientConfig({ - parameters: clientConfigParameters, - }); - - const {shortCode, apiName, organizationId, endpointPath} = - clientConfig.parameters; + 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}/${endpointPath}`; + const nockEndpointPath = `/custom/${apiName}/v2/organizations/${ + organizationId as string + }/${endpointPath}`; nock(nockBasePath).post(nockEndpointPath).query(true).reply(200); const expectedUrl = `${ @@ -120,7 +125,7 @@ describe('callCustomEndpoint', () => { }; const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); - await callCustomEndpoint(options, clientConfig, true); + await callCustomEndpoint({options, clientConfig, rawResponse: true}); expect(doFetchSpy).toBeCalledTimes(1); expect(doFetchSpy).toBeCalledWith( expectedUrl, @@ -129,4 +134,57 @@ describe('callCustomEndpoint', () => { 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', + }, + }; + + 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 + }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; + + 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 + ); + }); }); diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index a9f9edc..e5a7d38 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -11,10 +11,10 @@ import TemplateURL from '../templateUrl'; import {ClientConfigInit} from '../clientConfig'; export interface CustomParams { - apiName: string; + apiName?: string; apiVersion?: string; - endpointPath: string; - organizationId: string; + endpointPath?: string; + organizationId?: string; shortCode: string; [key: string]: unknown; } @@ -22,61 +22,78 @@ export interface CustomParams { /** * 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?q=custom+API) - * @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.parameters? - Query parameters that are added to the request - * @param options.headers? - Headers that are added to the request. Authorization header should be in this parameter 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.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 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.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 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 clientConfig.proxy? - Routes API calls through a proxy when set - * @param rawResponse? - Flag to return the raw response from the fetch call. True for raw response object, false for the data from the response + * @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 + * @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. + * @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 + * @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 ( +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 => { + }; + 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 (!clientConfig.parameters[arg]) { + if (!pathParams[arg]) { throw new Error( - `Missing required property in clientConfig.parameters: ${arg}` + `Missing required property needed in args.options.customApiPathParameters or args.clientConfig.parameters: ${arg}` ); } }); + if (!pathParams.apiVersion) { + pathParams.apiVersion = 'v1'; + } + const defaultBaseUri = 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; - let clientConfigCopy = clientConfig; - if (!clientConfig.baseUri || !clientConfig.parameters?.apiVersion) { + let clientConfigCopy = clientConfig; + if (!clientConfig.baseUri) { clientConfigCopy = { ...clientConfig, - ...(!clientConfig.baseUri && {baseUri: defaultBaseUri}), - parameters: { - ...clientConfig.parameters, - ...(!clientConfig.parameters?.apiVersion && {apiVersion: 'v1'}), - }, + baseUri: defaultBaseUri, }; } @@ -84,7 +101,7 @@ export const callCustomEndpoint = async ( '/organizations/{organizationId}/{endpointPath}', clientConfigCopy.baseUri as string, { - pathParams: clientConfigCopy.parameters as PathParameters, + pathParams: pathParams as PathParameters, queryParams: options.parameters, origin: clientConfigCopy.proxy, } From 944a273a29e8349dfc9f8b5ccba0daa971f294c1 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Thu, 25 Apr 2024 12:34:51 -0400 Subject: [PATCH 18/24] default application/json as content type and add test --- CHANGELOG.md | 2 +- README.md | 60 ++++++++++++++++++++-------- src/static/helpers/customApi.test.ts | 50 +++++++++++++++++++++-- src/static/helpers/customApi.ts | 33 ++++++++++++--- 4 files changed, 117 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a74f0..5fa234d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ #### Enchancements -- Add support for custom APIs with `callCustomEndpoint` helper function [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149) +- 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 diff --git a/README.md b/README.md index 068b7e4..5545d43 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Invalid query parameters that are not a part of the API and do not follow the `c ### Custom APIs -The SDK supports calling custom APIs with a helper function, `callCustomEndpoint`. +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: @@ -157,45 +157,71 @@ const SHORT_CODE = ""; const SITE_ID = ""; // client configuration parameters -const clientConfig = { +const clientConfigExample = { parameters: { clientId: CLIENT_ID, organizationId: ORG_ID, shortCode: SHORT_CODE, siteId: SITE_ID, - // Custom API path parameters - endpointPath: 'customers', - apiName: 'loyalty-info', - apiVersion: 'v1', // defaults to v1 if not provided - }, + } }; -// Flag to retrieve raw response or data from helper function -const rawResponse = false; +// Required params: apiName, endpointPath, shortCode, organizaitonId +// Required path params can be passed into: +// options.customApiPathParameters or clientConfig.parameters +const customApiArgs = { + endpointPath: 'customers', + apiName: 'loyalty-info', + apiVersion: 'v1', // defaults to v1 if not provided +} + const accessToken = ''; -let response = await helpers.callCustomEndpoint( - { +let getResponse = await helpers.callCustomEndpoint({ + options: { method: 'GET', parameters: { queryParameter: 'queryParameter1', siteId: SITE_ID, }, + 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', + siteId: SITE_ID, + }, headers: { 'Content-Type': 'application/json', authorization: `Bearer ${access_token}` - } + }, + customApiPathParameters: customApiArgs, + body: JSON.stringify({ data: 'data' }) }, - clientConfig, - rawResponse -) + clientConfig: clientConfigExample, + // Flag to retrieve raw response or data from helper function + rawResponse: false, +}) -console.log('RESPONSE: ', response) +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?q=custom+API) +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 diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index db000de..2fb5b31 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -37,11 +37,10 @@ describe('callCustomEndpoint', () => { endpointPath: 'endpoint_path', }, headers: { + 'Content-Type': 'text/plain', authorization: 'Bearer token', }, - body: { - data: 'data', - }, + body: 'Hello World', }; test('throws an error when required path parameters are not passed', () => { @@ -59,7 +58,7 @@ describe('callCustomEndpoint', () => { await callCustomEndpoint({options: copyOptions, clientConfig}); }) .rejects.toThrow( - 'Missing required property needed in args.options.customApiPathParameters or args.clientConfig.parameters: endpointPath' + 'Missing required property needed in options.customApiPathParameters or clientConfig.parameters: endpointPath' ) .finally(() => 'resolve promise'); }); @@ -187,4 +186,47 @@ describe('callCustomEndpoint', () => { 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 index e5a7d38..61dc399 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -21,20 +21,20 @@ export interface CustomParams { /** * 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?q=custom+API) + * 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 + * @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, 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. * @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 + * @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 @@ -77,7 +77,7 @@ export const callCustomEndpoint = async (args: { requiredArgs.forEach(arg => { if (!pathParams[arg]) { throw new Error( - `Missing required property needed in args.options.customApiPathParameters or args.clientConfig.parameters: ${arg}` + `Missing required property needed in options.customApiPathParameters or clientConfig.parameters: ${arg}` ); } }); @@ -97,15 +97,36 @@ export const callCustomEndpoint = async (args: { }; } + let contentTypeKey; + let optionsCopy = options; + + // Look for Content-Type header + if (options.headers) { + contentTypeKey = Object.keys(options.headers).find( + key => key.toLowerCase() === 'content-type' + ); + } + + // If Content-Type header does not exist, we default to "Content-Type": "application/json" + if (!contentTypeKey) { + optionsCopy = { + ...options, + headers: { + ...options.headers, + 'Content-Type': 'application/json', + }, + }; + } + const url = new TemplateURL( '/organizations/{organizationId}/{endpointPath}', clientConfigCopy.baseUri as string, { pathParams: pathParams as PathParameters, - queryParams: options.parameters, + queryParams: optionsCopy.parameters, origin: clientConfigCopy.proxy, } ); - return doFetch(url.toString(), options, clientConfigCopy, rawResponse); + return doFetch(url.toString(), optionsCopy, clientConfigCopy, rawResponse); }; From 0b0e5fb5ba479b30d375af550e36642b78d34c50 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Thu, 25 Apr 2024 12:51:33 -0400 Subject: [PATCH 19/24] add check for clientConfig headers --- src/static/helpers/customApi.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 61dc399..52d0948 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -27,11 +27,11 @@ export interface CustomParams { * @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, it will be defaulted to "application/json". + * @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. + * @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) @@ -106,6 +106,11 @@ export const callCustomEndpoint = async (args: { key => key.toLowerCase() === 'content-type' ); } + if(clientConfigCopy.headers && !contentTypeKey) { + contentTypeKey = Object.keys(clientConfigCopy.headers).find( + key => key.toLowerCase() === 'content-type' + ); + } // If Content-Type header does not exist, we default to "Content-Type": "application/json" if (!contentTypeKey) { From efc18634010b4ba927bd1782e5d3aebfc369d8a8 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Thu, 25 Apr 2024 12:54:16 -0400 Subject: [PATCH 20/24] lint --- src/static/helpers/customApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 52d0948..53c64f9 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -106,7 +106,7 @@ export const callCustomEndpoint = async (args: { key => key.toLowerCase() === 'content-type' ); } - if(clientConfigCopy.headers && !contentTypeKey) { + if (clientConfigCopy.headers && !contentTypeKey) { contentTypeKey = Object.keys(clientConfigCopy.headers).find( key => key.toLowerCase() === 'content-type' ); From 3522f362c26acbba15addcea0c022597ea4c507b Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Mon, 29 Apr 2024 10:34:02 -0400 Subject: [PATCH 21/24] use siteId from clientConfig --- README.md | 4 +-- src/static/helpers/customApi.test.ts | 30 +++++++++++++++---- src/static/helpers/customApi.ts | 45 ++++++++++++++++++---------- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 5545d43..5d5faea 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,9 @@ const clientConfigExample = { // Required path params can be passed into: // options.customApiPathParameters or clientConfig.parameters const customApiArgs = { - endpointPath: 'customers', apiName: 'loyalty-info', apiVersion: 'v1', // defaults to v1 if not provided + endpointPath: 'customers' } const accessToken = ''; @@ -182,7 +182,6 @@ let getResponse = await helpers.callCustomEndpoint({ method: 'GET', parameters: { queryParameter: 'queryParameter1', - siteId: SITE_ID, }, headers: { // Content-Type is defaulted to application/json if not provided @@ -201,7 +200,6 @@ let postResponse = await helpers.callCustomEndpoint({ method: 'POST', parameters: { queryParameter: 'queryParameter1', - siteId: SITE_ID, }, headers: { 'Content-Type': 'application/json', diff --git a/src/static/helpers/customApi.test.ts b/src/static/helpers/customApi.test.ts index 2fb5b31..28943d1 100644 --- a/src/static/helpers/customApi.test.ts +++ b/src/static/helpers/customApi.test.ts @@ -43,6 +43,21 @@ describe('callCustomEndpoint', () => { 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, @@ -84,7 +99,9 @@ describe('callCustomEndpoint', () => { const expectedUrl = `${ nockBasePath + nockEndpointPath - }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; + }?${queryParamString}`; + const expectedOptions = addSiteIdToOptions(copyOptions); + const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); const response = (await callCustomEndpoint({ @@ -97,7 +114,7 @@ describe('callCustomEndpoint', () => { expect(doFetchSpy).toBeCalledTimes(1); expect(doFetchSpy).toBeCalledWith( expectedUrl, - copyOptions, + expectedOptions, expect.anything(), true ); @@ -116,7 +133,9 @@ describe('callCustomEndpoint', () => { const expectedUrl = `${ nockBasePath + nockEndpointPath - }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; + }?${queryParamString}`; + const expectedOptions = addSiteIdToOptions(options); + const expectedClientConfig = { ...clientConfig, baseUri: @@ -128,7 +147,7 @@ describe('callCustomEndpoint', () => { expect(doFetchSpy).toBeCalledTimes(1); expect(doFetchSpy).toBeCalledWith( expectedUrl, - options, + expectedOptions, expectedClientConfig, true ); @@ -144,6 +163,7 @@ describe('callCustomEndpoint', () => { shortCode: 'clientconfig_shortcode', apiVersion: 'v2', organizationId: 'clientConfig_organizationId', + siteId: 'site_id', }, }; @@ -171,7 +191,7 @@ describe('callCustomEndpoint', () => { // expected URL is a mix of both params const expectedUrl = `${ nockBasePath + nockEndpointPath - }?queryParam1=query+parameter+1&queryParam2=query+parameter+2`; + }?${queryParamString}`; const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch'); await callCustomEndpoint({ diff --git a/src/static/helpers/customApi.ts b/src/static/helpers/customApi.ts index 53c64f9..1bcc8ea 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -10,6 +10,20 @@ import {doFetch} from './fetchHelper'; import TemplateURL from '../templateUrl'; import {ClientConfigInit} from '../clientConfig'; +// 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; @@ -97,28 +111,27 @@ export const callCustomEndpoint = async (args: { }; } - let contentTypeKey; - let optionsCopy = options; + // 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); - // Look for Content-Type header - if (options.headers) { - contentTypeKey = Object.keys(options.headers).find( - key => key.toLowerCase() === 'content-type' - ); - } - if (clientConfigCopy.headers && !contentTypeKey) { - contentTypeKey = Object.keys(clientConfigCopy.headers).find( - key => key.toLowerCase() === 'content-type' - ); - } + let optionsCopy = options; - // If Content-Type header does not exist, we default to "Content-Type": "application/json" - if (!contentTypeKey) { + if (!contentTypeExists || useSiteId) { optionsCopy = { ...options, headers: { ...options.headers, - 'Content-Type': 'application/json', + // 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}), }, }; } From 88f58f59ebf7089a7ab7ba8e9751a300480829ac Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Mon, 29 Apr 2024 10:36:46 -0400 Subject: [PATCH 22/24] update README --- README.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5d5faea..8b60eac 100644 --- a/README.md +++ b/README.md @@ -151,18 +151,13 @@ Example usage: import pkg from 'commerce-sdk-isomorphic'; const { helpers } = pkg; -const CLIENT_ID = ""; -const ORG_ID = ""; -const SHORT_CODE = ""; -const SITE_ID = ""; - // client configuration parameters const clientConfigExample = { parameters: { - clientId: CLIENT_ID, - organizationId: ORG_ID, - shortCode: SHORT_CODE, - siteId: SITE_ID, + clientId: "", + organizationId: "", + shortCode: "", + siteId: "", } }; @@ -202,7 +197,6 @@ let postResponse = await helpers.callCustomEndpoint({ queryParameter: 'queryParameter1', }, headers: { - 'Content-Type': 'application/json', authorization: `Bearer ${access_token}` }, customApiPathParameters: customApiArgs, From c9579bd7e05b62a5bfc586c9e4eac946275c680d Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Mon, 29 Apr 2024 10:40:39 -0400 Subject: [PATCH 23/24] remove comment --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 8b60eac..52ebebc 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,6 @@ Example usage: import pkg from 'commerce-sdk-isomorphic'; const { helpers } = pkg; -// client configuration parameters const clientConfigExample = { parameters: { clientId: "", From 5fb1e170f9a5d40f755ca507505176bafb83ca36 Mon Sep 17 00:00:00 2001 From: Joel Uong Date: Mon, 29 Apr 2024 15:34:10 -0400 Subject: [PATCH 24/24] pull out default base URI into config file --- README.md | 6 +++++- src/static/config.ts | 9 +++++++++ src/static/helpers/customApi.ts | 6 ++---- 3 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 src/static/config.ts diff --git a/README.md b/README.md index 52ebebc..76f0c9a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,11 @@ const clientConfigExample = { 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 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.ts b/src/static/helpers/customApi.ts index 1bcc8ea..8655611 100644 --- a/src/static/helpers/customApi.ts +++ b/src/static/helpers/customApi.ts @@ -9,6 +9,7 @@ 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 @@ -100,14 +101,11 @@ export const callCustomEndpoint = async (args: { pathParams.apiVersion = 'v1'; } - const defaultBaseUri = - 'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}'; - let clientConfigCopy = clientConfig; if (!clientConfig.baseUri) { clientConfigCopy = { ...clientConfig, - baseUri: defaultBaseUri, + baseUri: CUSTOM_API_DEFAULT_BASE_URI, }; }