Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: support custom APIs @W-15111169@ #149

Merged
merged 24 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c347cdb
add custom endpoint helper
joeluong-sfcc Apr 15, 2024
f640715
refactor
joeluong-sfcc Apr 16, 2024
5422ecb
implement runFetchHelper
joeluong-sfcc Apr 16, 2024
e14fa98
use runFetchHelper in operations handlebar template
joeluong-sfcc Apr 17, 2024
afe02d3
fix broken calls
joeluong-sfcc Apr 18, 2024
b570a91
fix linting errors and most type errors
joeluong-sfcc Apr 18, 2024
fef40ae
Refactor and add tsdoc comments
joeluong-sfcc Apr 18, 2024
04e4ae1
refactor and add unit test
joeluong-sfcc Apr 19, 2024
dd26eae
add comment for test coverage
joeluong-sfcc Apr 19, 2024
55afa1d
add unit test and update changelog
joeluong-sfcc Apr 19, 2024
2ee3c0e
update type for body
joeluong-sfcc Apr 22, 2024
b646330
address PR comments
joeluong-sfcc Apr 23, 2024
48dcd28
add example in README
joeluong-sfcc Apr 23, 2024
ef6033a
update types and allow baseUri as argument
joeluong-sfcc Apr 24, 2024
d73dfab
lint
joeluong-sfcc Apr 24, 2024
237e7cc
add check in test for response status code
joeluong-sfcc Apr 24, 2024
f7ab768
combine params into 1 object and pull out custom path params into opt…
joeluong-sfcc Apr 24, 2024
944a273
default application/json as content type and add test
joeluong-sfcc Apr 25, 2024
0b0e5fb
add check for clientConfig headers
joeluong-sfcc Apr 25, 2024
efc1863
lint
joeluong-sfcc Apr 25, 2024
3522f36
use siteId from clientConfig
joeluong-sfcc Apr 29, 2024
88f58f5
update README
joeluong-sfcc Apr 29, 2024
c9579bd
remove comment
joeluong-sfcc Apr 29, 2024
5fb1e17
pull out default base URI into config file
joeluong-sfcc Apr 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 support for custom APIs with `callCustomEndpoint` helper function [#149](https://github.com/SalesforceCommerceCloud/commerce-sdk-isomorphic/pull/149)
joeluong-sfcc marked this conversation as resolved.
Show resolved Hide resolved

## v1.13.1

#### Bug fixes
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
joeluong-sfcc marked this conversation as resolved.
Show resolved Hide resolved

Example usage:

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

const CLIENT_ID = "<your-client-id>";
const ORG_ID = "<your-org-id>";
const SHORT_CODE = "<your-short-code>";
const SITE_ID = "<your-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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clientConfig normally contains a static values – clientId|organizationId|shortCode|siteId – these things rarely if ever change between calls in regular implementations.

endpointPath|apiName|apiVersion are likely to change.

Would you consider moving these values into the options of the request? At least to me, if feels more natural to have them there with other options that usually vary call to call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new property to options called customApiPathParameters

customApiPathParameters?: {
    apiName?: string;
    apiVersion?: string;
    endpointPath?: string;
    organizationId?: string;
    shortCode?: string;
  };

path parameters can now be passed either in options.customApiPathParameters or clientConfig.parameters.

},
};

// Flag to retrieve raw response or data from helper function
const rawResponse = false;
const accessToken = '<INSERT ACCESS TOKEN HERE>';

let response = await helpers.callCustomEndpoint(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly, but would you consider an alternative argument structure for callCustomEndpoint?

await helpers.callCustomEndpoint({
  options: myOptions,
  clientConfig: myClientConfig,
  rawResponse: true
})

This interface makes it much more clear to folks using regular old JavaScript what the impact of the three arguments will be.

It would also allow you to significantly shrink this code example:

await helpers.callCustomEndpoint({
  options: {
    method: "GET",
    parameters: {
      queryParameter: "queryParameter1",
      siteId: SITE_ID,
    },
    headers: {
      "Content-Type": "application/json",
      authorization: `Bearer <your-access-token>`,
    },
  },
  clientConfig: {
    clientId: "<your-client-id>",
    organizationId: "<your-org-id>",
    shortCode: "<your-short-code>",
    siteId: "<your-site-id>",
    // Custom API path parameters
    endpointPath: "customers",
    apiName: "loyalty-info",
    apiVersion: "v1", // defaults to v1 if not provided
  },
  rawResponse: true,
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you also consider showing an example of a mutation request eg. a POST where you pass a request body?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated argument structure to be a single argument, following the pattern provided above:

await helpers.callCustomEndpoint({
  options: myOptions,
  clientConfig: myClientConfig,
  rawResponse: true
})

and updated README example to reflect this new change and provide a mutation request example

{
method: 'GET',
parameters: {
queryParameter: 'queryParameter1',
siteId: SITE_ID,
},
headers: {
'Content-Type': 'application/json',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we expect callers to pass the content type or can it be automatically inferred or default to JSON?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated implementation to default to "Content-Type" : "application/json" if Content-Type header is not provided

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)
joeluong-sfcc marked this conversation as resolved.
Show resolved Hide resolved

## License Information

The Commerce SDK Isomorphic is licensed under BSD-3-Clause license. See the [license](./LICENSE.txt) for details.
132 changes: 132 additions & 0 deletions src/static/helpers/customApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* 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,
});

expect(async () => {
// eslint-disable-next-line
// @ts-ignore <-- we know it'll complain since we removed apiName
await callCustomEndpoint(options, clientConfig);
})
.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 doFetchSpy = jest.spyOn(fetchHelper, 'doFetch');

const response = (await callCustomEndpoint(
options,
clientConfig,
true
)) as Response;

expect(response.status).toBe(200);
expect(doFetchSpy).toBeCalledTimes(1);
joeluong-sfcc marked this conversation as resolved.
Show resolved Hide resolved
expect(doFetchSpy).toBeCalledWith(
expectedUrl,
options,
expect.anything(),
true
);
expect(expectedUrl).toContain('/v1/');
});

test('doFetch 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 doFetchSpy = jest.spyOn(fetchHelper, 'doFetch');
await callCustomEndpoint(options, clientConfig, true);
expect(doFetchSpy).toBeCalledTimes(1);
expect(doFetchSpy).toBeCalledWith(
expectedUrl,
options,
expectedClientConfig,
true
);
});
});
94 changes: 94 additions & 0 deletions src/static/helpers/customApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2024, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {BodyInit} from 'node-fetch';
import {PathParameters} from './types';
import {doFetch} from './fetchHelper';
import TemplateURL from '../templateUrl';
import {ClientConfigInit} from '../clientConfig';

export interface CustomParams {
apiName: string;
apiVersion?: string;
endpointPath: string;
organizationId: string;
shortCode: string;
[key: string]: unknown;
}

/**
* A helper function designed to make calls to a custom API endpoint
* For more information about custom APIs, please refer to the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-apis.html?q=custom+API)
joeluong-sfcc marked this conversation as resolved.
Show resolved Hide resolved
* @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
joeluong-sfcc marked this conversation as resolved.
Show resolved Hide resolved
* @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
*/
export const callCustomEndpoint = async (
options: {
method?: string;
parameters?: {
[key: string]: string | number | boolean | string[] | number[];
};
headers?: {
authorization?: string;
} & {[key: string]: string};
body?: BodyInit | globalThis.BodyInit | unknown;
},
clientConfig: ClientConfigInit<CustomParams>,
rawResponse?: boolean
): Promise<Response | unknown> => {
const requiredArgs = [
'apiName',
'endpointPath',
'organizationId',
'shortCode',
];
requiredArgs.forEach(arg => {
if (!clientConfig.parameters[arg]) {
throw new Error(
`Missing required property in clientConfig.parameters: ${arg}`
);
}
});

const defaultBaseUri =
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We previously talked about this decision on hard coding prod uri.. are we cool with that. Have we documented it anywhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the default base URI if you don't provide one yourself. You can override this behavior by passing in your own baseUri in the clientConfig

const clientConfig = {
  parameters: {
      ...
  },
  baseUri: 'https://{shortCode}.alternativeBaseUri.com/custom/{apiName}/{apiVersion}'
};

Behavior is documented in the tsdoc comments. When we release and run the generate doc command it'll populate the static docs site:

 * @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}'

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess my question is, and I think I know the answer. But if you were to generate a client where the raml specifies the base url a something like 'https://{shortCode}.dev-api.commercecloud.salesforce.com/custom/{apiName}/{apiVersion}' notice the "dev" in "dev-api" all calls to the apis will goto that url. but if I make a custom api call without passing that "dev" value, it will make it to production. Correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because the callCustomEndpoint helper function does not interact with any type of RAML, it'll use the production URI as default if you don't provide a base URI.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bendvc I think you are right. Unless the user overrides baseUri it will always go to the default. @joeluong-sfcc Can we read the default value from a config file instead of hard-coding so that it is easy to change the default value

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be we shouldn't have default URI. How about just throwing an error if the baseUri is not provided?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the default base URI into a config file. I think it still makes sense to have a default URI as all the other APIs use the production URI as default, but I've made it more clear in the README example on how to pass it into the function. Does that work for y'all @unandyala @bendvc?

'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 PathParameters,
queryParams: options.parameters,
origin: clientConfigCopy.proxy,
}
);

return doFetch(url.toString(), options, clientConfigCopy, rawResponse);
};
2 changes: 2 additions & 0 deletions src/static/helpers/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading