Skip to content

Commit

Permalink
Feat: support custom APIs @W-15111169@ (#149)
Browse files Browse the repository at this point in the history
* add custom endpoint helper

* refactor

* implement runFetchHelper

* use runFetchHelper in operations handlebar template

* fix broken calls

* fix linting errors and most type errors

* Refactor and add tsdoc comments

* refactor and add unit test

* add comment for test coverage

* add unit test and update changelog

* update type for body

* address PR comments

* add example in README

* update types and allow baseUri as argument

* lint

* add check in test for response status code

* combine params into 1 object and pull out custom path params into options

* default application/json as content type and add test

* add check for clientConfig headers

* lint

* use siteId from clientConfig

* update README

* remove comment

* pull out default base URI into config file
  • Loading branch information
joeluong-sfcc authored Apr 30, 2024
1 parent fa49335 commit 93358e3
Show file tree
Hide file tree
Showing 11 changed files with 724 additions and 19 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# CHANGELOG

## v1.14.0-dev

#### Enchancements

- Add helper function `callCustomEndpoint` to call [Custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) - [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149)

## v1.13.1

#### Bug fixes
Expand Down
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,83 @@ const searchResult = await shopperSearch.productSearch({

Invalid query parameters that are not a part of the API and do not follow the `c_` custom query parameter convention will be filtered from the request and a warning will be displayed.

### Custom APIs

The SDK supports calling [custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html) with a helper function, `callCustomEndpoint`.

Example usage:

```javascript
import pkg from 'commerce-sdk-isomorphic';
const { helpers } = pkg;

const clientConfigExample = {
parameters: {
clientId: "<your-client-id>",
organizationId: "<your-org-id>",
shortCode: "<your-short-code>",
siteId: "<your-site-id>",
},
// 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: "<your-base-uri>"
};

// Required params: apiName, endpointPath, shortCode, organizaitonId
// Required path params can be passed into:
// options.customApiPathParameters or clientConfig.parameters
const customApiArgs = {
apiName: 'loyalty-info',
apiVersion: 'v1', // defaults to v1 if not provided
endpointPath: 'customers'
}

const accessToken = '<INSERT ACCESS TOKEN HERE>';

let getResponse = await helpers.callCustomEndpoint({
options: {
method: 'GET',
parameters: {
queryParameter: 'queryParameter1',
},
headers: {
// Content-Type is defaulted to application/json if not provided
'Content-Type': 'application/json',
authorization: `Bearer ${access_token}`
},
customApiPathParameters: customApiArgs
},
clientConfig: clientConfigExample,
// Flag to retrieve raw response or data from helper function
rawResponse: false,
})

let postResponse = await helpers.callCustomEndpoint({
options: {
method: 'POST',
parameters: {
queryParameter: 'queryParameter1',
},
headers: {
authorization: `Bearer ${access_token}`
},
customApiPathParameters: customApiArgs,
body: JSON.stringify({ data: 'data' })
},
clientConfig: clientConfigExample,
// Flag to retrieve raw response or data from helper function
rawResponse: false,
})

console.log('get response: ', getResponse)
console.log('post response: ', postResponse)
```

For more documentation about this helper function, please refer to the [commerce-sdk-isomorphic docs](https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/modules/helpers.html).

For more information about custom APIs, please refer to the [Salesforce Developer Docs](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html)

## License Information

The Commerce SDK Isomorphic is licensed under BSD-3-Clause license. See the [license](./LICENSE.txt) for details.
9 changes: 9 additions & 0 deletions src/static/config.ts
Original file line number Diff line number Diff line change
@@ -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}';
252 changes: 252 additions & 0 deletions src/static/helpers/customApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/*
* Copyright (c) 2024, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import nock from 'nock';
import {callCustomEndpoint, CustomParams} from './customApi';
import * as fetchHelper from './fetchHelper';
import ClientConfig from '../clientConfig';

describe('callCustomEndpoint', () => {
beforeEach(() => {
jest.restoreAllMocks();
nock.cleanAll();
});

const clientConfig = new ClientConfig<CustomParams>({
parameters: {
shortCode: 'short_code',
organizationId: 'organization_id',
clientId: 'client_id',
siteId: 'site_id',
},
});

const options = {
method: 'POST',
parameters: {
queryParam1: 'query parameter 1',
queryParam2: 'query parameter 2',
},
customApiPathParameters: {
apiName: 'api_name',
apiVersion: 'v2',
endpointPath: 'endpoint_path',
},
headers: {
'Content-Type': 'text/plain',
authorization: 'Bearer token',
},
body: 'Hello World',
};

const queryParamString = new URLSearchParams({
...options.parameters,
siteId: clientConfig.parameters.siteId as string,
}).toString();

// helper function that creates a copy of the options object
// and adds siteId to the parameters object that comes from clientConfig
const addSiteIdToOptions = (optionsObj: Record<string, unknown>) => ({
...optionsObj,
parameters: {
...(optionsObj.parameters as Record<string, unknown>),
siteId: clientConfig.parameters.siteId,
},
});

test('throws an error when required path parameters are not passed', () => {
const copyOptions = {
...options,
// omit endpointPath
customApiPathParameters: {
apiName: 'api_name',
},
};

expect(async () => {
// eslint-disable-next-line
// @ts-ignore <-- we know it'll complain since we removed endpointPath
await callCustomEndpoint({options: copyOptions, clientConfig});
})
.rejects.toThrow(
'Missing required property needed in options.customApiPathParameters or clientConfig.parameters: endpointPath'
)
.finally(() => 'resolve promise');
});

test('sets api version to "v1" if not provided', async () => {
const copyOptions = {
...options,
// omit apiVersion
customApiPathParameters: {
endpointPath: 'endpoint_path',
apiName: 'api_name',
},
};

const {shortCode, organizationId} = clientConfig.parameters;
const {apiName, endpointPath} = copyOptions.customApiPathParameters;

const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`;
const nockEndpointPath = `/custom/${apiName}/v1/organizations/${
organizationId as string
}/${endpointPath}`;
nock(nockBasePath).post(nockEndpointPath).query(true).reply(200);

const expectedUrl = `${
nockBasePath + nockEndpointPath
}?${queryParamString}`;
const expectedOptions = addSiteIdToOptions(copyOptions);

const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch');

const response = (await callCustomEndpoint({
options: copyOptions,
clientConfig,
rawResponse: true,
})) as Response;

expect(response.status).toBe(200);
expect(doFetchSpy).toBeCalledTimes(1);
expect(doFetchSpy).toBeCalledWith(
expectedUrl,
expectedOptions,
expect.anything(),
true
);
expect(expectedUrl).toContain('/v1/');
});

test('doFetch is called with the correct arguments', async () => {
const {shortCode, organizationId} = clientConfig.parameters;
const {apiName, endpointPath} = options.customApiPathParameters;

const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`;
const nockEndpointPath = `/custom/${apiName}/v2/organizations/${
organizationId as string
}/${endpointPath}`;
nock(nockBasePath).post(nockEndpointPath).query(true).reply(200);

const expectedUrl = `${
nockBasePath + nockEndpointPath
}?${queryParamString}`;
const expectedOptions = addSiteIdToOptions(options);

const expectedClientConfig = {
...clientConfig,
baseUri:
'https://{shortCode}.api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}',
};

const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch');
await callCustomEndpoint({options, clientConfig, rawResponse: true});
expect(doFetchSpy).toBeCalledTimes(1);
expect(doFetchSpy).toBeCalledWith(
expectedUrl,
expectedOptions,
expectedClientConfig,
true
);
});

test('uses path params from options and clientConfig, prioritizing options', async () => {
const copyClientConfig = {
...clientConfig,
// Only shortCode will be used
parameters: {
endpointPath: 'clientConfig_endpoint_path',
apiName: 'clientConfig_api_name',
shortCode: 'clientconfig_shortcode',
apiVersion: 'v2',
organizationId: 'clientConfig_organizationId',
siteId: 'site_id',
},
};

const copyOptions = {
...options,
// these parameters will be prioritzed
customApiPathParameters: {
endpointPath: 'customApiPathParameters_endpoint_path',
apiName: 'customApiPathParameters_api_name',
apiVersion: 'v3',
organizationId: 'customApiPathParameters_organizationId',
},
};

// nock interception should be using custom API path parameters from options
const {apiName, endpointPath, organizationId, apiVersion} =
copyOptions.customApiPathParameters;
// except shortcode since we didn't implement it in copyOptions.customApiPathParameters
const {shortCode} = copyClientConfig.parameters;

const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`;
const nockEndpointPath = `/custom/${apiName}/${apiVersion}/organizations/${organizationId}/${endpointPath}`;
nock(nockBasePath).post(nockEndpointPath).query(true).reply(200);

// expected URL is a mix of both params
const expectedUrl = `${
nockBasePath + nockEndpointPath
}?${queryParamString}`;

const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch');
await callCustomEndpoint({
options: copyOptions,
clientConfig: copyClientConfig,
});
expect(doFetchSpy).toBeCalledTimes(1);
expect(doFetchSpy).toBeCalledWith(
expectedUrl,
expect.anything(),
expect.anything(),
undefined
);
});

test('uses application/json as default content type if not provided', async () => {
const copyOptions = {
...options,
// exclude Content-Type
headers: {
authorization: 'Bearer token',
},
};

const {apiName, endpointPath, apiVersion} =
copyOptions.customApiPathParameters;
const {shortCode, organizationId} = clientConfig.parameters;

const expectedJsonHeaders = {
authorization: 'Bearer token',
'Content-Type': 'application/json',
};

const nockBasePath = `https://${shortCode}.api.commercecloud.salesforce.com`;
const nockEndpointPath = `/custom/${apiName}/${apiVersion}/organizations/${
organizationId as string
}/${endpointPath}`;
nock(nockBasePath, {
reqheaders: expectedJsonHeaders,
})
.post(nockEndpointPath)
.query(true)
.reply(200);

const doFetchSpy = jest.spyOn(fetchHelper, 'doFetch');
await callCustomEndpoint({
options: copyOptions,
clientConfig,
});
expect(doFetchSpy).toBeCalledTimes(1);
expect(doFetchSpy).toBeCalledWith(
expect.any(String),
expect.objectContaining({headers: expectedJsonHeaders}),
expect.anything(),
undefined
);
});
});
Loading

0 comments on commit 93358e3

Please sign in to comment.