Skip to content

Commit

Permalink
Merge pull request #1793 from SalesforceCommerceCloud/custom-endpoint…
Browse files Browse the repository at this point in the history
…-support

[commerce-sdk-react] SCAPI Custom endpoint support
  • Loading branch information
kevinxh authored May 22, 2024
2 parents 15ca63d + 84b4b4a commit dcfae70
Show file tree
Hide file tree
Showing 11 changed files with 383 additions and 8 deletions.
1 change: 1 addition & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## v2.0.0-dev (May 21, 2024)
- Upgrade to commerce-sdk-isomorphic v2.0.0 [#1794](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1794)
- Add `useCustomQuery` and `useCustomMutation` for SCAPI custom endpoint support [#1793](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1793)
- Add Shopper Stores hooks [#1788](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1788)

## v1.4.2 (Apr 17, 2024)
Expand Down
2 changes: 2 additions & 0 deletions packages/commerce-sdk-react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ export {default as useEncUserId} from './useEncUserId'
export {default as useUsid} from './useUsid'
export {default as useCustomerId} from './useCustomerId'
export {default as useCustomerType} from './useCustomerType'
export {useCustomQuery} from './useQuery'
export {useCustomMutation} from './useMutation'
10 changes: 10 additions & 0 deletions packages/commerce-sdk-react/src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ShopperSeo,
ShopperStores
} from 'commerce-sdk-isomorphic'
import {helpers} from 'commerce-sdk-isomorphic'

// --- GENERAL UTILITIES --- //

Expand Down Expand Up @@ -210,3 +211,12 @@ export type CacheUpdateMatrix<Client extends ApiClient> = {
? CacheUpdateGetter<MergedOptions<Client, Argument<Client[Method]>>, Data>
: never
}

type CustomEndpointArg = Parameters<typeof helpers.callCustomEndpoint>[0]
type CustomEndpointArgClientConfig = CustomEndpointArg['clientConfig']
// The commerce-sdk-isomorphic custom endpoint helper REQUIRES clientConfig as mandatory argument
// But we inject the configs for users from the provider, so this custom type is created
// to make clientConfig optional when calling useCustomQuery/useCustomMutation
export type OptionalCustomEndpointClientConfig = Omit<CustomEndpointArg, 'clientConfig'> & {
clientConfig?: CustomEndpointArgClientConfig
}
84 changes: 84 additions & 0 deletions packages/commerce-sdk-react/src/hooks/useMutation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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 nock from 'nock'
import {act} from '@testing-library/react'
import {
mockMutationEndpoints,
renderHookWithProviders,
waitAndExpectSuccess,
DEFAULT_TEST_CONFIG
} from '../test-utils'
import {useCustomMutation} from './useMutation'

jest.mock('../auth/index.ts', () => {
const {default: mockAuth} = jest.requireActual('../auth/index.ts')
mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'})
return mockAuth
})

describe('useCustomMutation', () => {
beforeEach(() => nock.cleanAll())
test('useCustomMutation returns data on success', async () => {
const mockRes = {data: '123'}
const apiName = 'hello-world'
mockMutationEndpoints(apiName, mockRes)
const {result} = renderHookWithProviders(() => {
const clientConfig = {
parameters: {
clientId: 'CLIENT_ID',
siteId: 'SITE_ID',
organizationId: 'ORG_ID',
shortCode: 'SHORT_CODE'
},
proxy: 'http://localhost:8888/mobify/proxy/api'
}
return useCustomMutation({
options: {
method: 'POST',
customApiPathParameters: {
endpointPath: 'test-hello-world',
apiName
},
body: {test: '123'}
},
clientConfig,
rawResponse: false
})
})
expect(result.current.data).toBeUndefined()
act(() => result.current.mutate())
await waitAndExpectSuccess(() => result.current)
expect(result.current.data).toEqual(mockRes)
})
test('clientConfig is optional, default to CommerceApiProvider configs', async () => {
const mockRes = {data: '123'}
const apiName = 'hello-world'
const endpointPath = 'test-hello-world'
mockMutationEndpoints(
`${apiName}/v1/organizations/${DEFAULT_TEST_CONFIG.organizationId}/${endpointPath}`,
mockRes
)
const {result} = renderHookWithProviders(() => {
return useCustomMutation({
options: {
method: 'POST',
customApiPathParameters: {
endpointPath: 'test-hello-world',
apiName
},
body: {test: '123'}
},
rawResponse: false
})
})
expect(result.current.data).toBeUndefined()
act(() => result.current.mutate())
await waitAndExpectSuccess(() => result.current)
expect(result.current.data).toEqual(mockRes)
})
})
66 changes: 64 additions & 2 deletions packages/commerce-sdk-react/src/hooks/useMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@
* 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 {useMutation as useReactQueryMutation, useQueryClient} from '@tanstack/react-query'
import {CacheUpdateGetter, ApiOptions, ApiMethod, ApiClient, MergedOptions} from './types'
import {
useMutation as useReactQueryMutation,
useQueryClient,
UseMutationOptions
} from '@tanstack/react-query'
import {helpers} from 'commerce-sdk-isomorphic'
import useAuthContext from './useAuthContext'
import useConfig from './useConfig'
import {
ApiClient,
ApiMethod,
ApiOptions,
CacheUpdateGetter,
MergedOptions,
OptionalCustomEndpointClientConfig
} from './types'
import {useAuthorizationHeader} from './useAuthorizationHeader'
import useCustomerId from './useCustomerId'
import {mergeOptions, updateCache} from './utils'
Expand Down Expand Up @@ -38,3 +52,51 @@ export const useMutation = <
}
})
}

/**
* A hook for SCAPI custom endpoint mutations.
*
* Besides calling custom endpoint, this hook does a few things for better DX.
* 1. inject access token
* 2. merge SCAPI client configurations from the CommerceApiProvider
* @param apiOptions - Options passed through to commerce-sdk-isomorphic
* @param mutationOptions - Options passed through to @tanstack/react-query
* @returns A TanStack Query mutation hook with data from the custom API endpoint.
*/
export const useCustomMutation = (
apiOptions: OptionalCustomEndpointClientConfig,
mutationOptions?: UseMutationOptions
) => {
const config = useConfig()
const auth = useAuthContext()
const callCustomEndpointWithAuth = (options: OptionalCustomEndpointClientConfig) => {
return async () => {
const clientConfig = options.clientConfig || {}
const clientHeaders = config.headers || {}
const {access_token} = await auth.ready()
return await helpers.callCustomEndpoint({
...options,
options: {
...options.options,
headers: {
Authorization: `Bearer ${access_token}`,
...clientHeaders,
...options.options?.headers
}
},
clientConfig: {
parameters: {
clientId: config.clientId,
siteId: config.siteId,
organizationId: config.organizationId,
shortCode: config.organizationId
},
proxy: config.proxy,
...clientConfig
}
})
}
}

return useReactQueryMutation(callCustomEndpointWithAuth(apiOptions), mutationOptions)
}
105 changes: 105 additions & 0 deletions packages/commerce-sdk-react/src/hooks/useQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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 nock from 'nock'
import {
mockQueryEndpoint,
renderHookWithProviders,
waitAndExpectSuccess,
DEFAULT_TEST_CONFIG
} from '../test-utils'
import {useCustomQuery} from './useQuery'

jest.mock('../auth/index.ts', () => {
const {default: mockAuth} = jest.requireActual('../auth/index.ts')
mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'})
return mockAuth
})

describe('useCustomQuery', () => {
beforeEach(() => nock.cleanAll())
afterEach(() => {
expect(nock.pendingMocks()).toHaveLength(0)
})
test('useCustomQuery returns data on success', async () => {
const mockRes = {data: '123'}
const apiName = 'hello-world'
mockQueryEndpoint(apiName, mockRes)
const {result} = renderHookWithProviders(() => {
const clientConfig = {
parameters: {
clientId: 'CLIENT_ID',
siteId: 'SITE_ID',
organizationId: 'ORG_ID',
shortCode: 'SHORT_CODE'
},
proxy: 'http://localhost:8888/mobify/proxy/api'
}
return useCustomQuery({
options: {
method: 'GET',
customApiPathParameters: {
apiVersion: 'v1',
endpointPath: 'test-hello-world',
apiName
}
},
clientConfig,
rawResponse: false
})
})
await waitAndExpectSuccess(() => result.current)
expect(result.current.data).toEqual(mockRes)
})
test('clientConfig is optional, default to CommerceApiProvider configs', async () => {
const mockRes = {data: '123'}
const apiName = 'hello-world'
const endpointPath = 'test-hello-world'
mockQueryEndpoint(
`${apiName}/v1/organizations/${DEFAULT_TEST_CONFIG.organizationId}/${endpointPath}`,
mockRes
)
const {result} = renderHookWithProviders(() => {
return useCustomQuery({
options: {
method: 'GET',
customApiPathParameters: {
apiVersion: 'v1',
endpointPath,
apiName
}
},
rawResponse: false
})
})
await waitAndExpectSuccess(() => result.current)
expect(result.current.data).toEqual(mockRes)
})
test('query defaults to GET request', async () => {
const mockRes = {data: '123'}
const apiName = 'hello-world'
const endpointPath = 'test-hello-world'
mockQueryEndpoint(
`${apiName}/v1/organizations/${DEFAULT_TEST_CONFIG.organizationId}/${endpointPath}`,
mockRes
)
const {result} = renderHookWithProviders(() => {
return useCustomQuery({
options: {
customApiPathParameters: {
apiVersion: 'v1',
endpointPath,
apiName
}
},
rawResponse: false
})
})
await waitAndExpectSuccess(() => result.current)
expect(result.current.data).toEqual(mockRes)
})
})
78 changes: 76 additions & 2 deletions packages/commerce-sdk-react/src/hooks/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
* 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 {useQuery as useReactQuery} from '@tanstack/react-query'
import {useQuery as useReactQuery, UseQueryOptions} from '@tanstack/react-query'
import {helpers} from 'commerce-sdk-isomorphic'
import {useAuthorizationHeader} from './useAuthorizationHeader'
import useAuthContext from './useAuthContext'
import {
ApiClient,
ApiMethod,
Expand All @@ -14,8 +16,10 @@ import {
ApiQueryOptions,
MergedOptions,
NullableParameters,
OmitNullableParameters
OmitNullableParameters,
OptionalCustomEndpointClientConfig
} from './types'
import useConfig from './useConfig'
import {hasAllKeys} from './utils'
import {onClient} from '../utils'

Expand Down Expand Up @@ -66,3 +70,73 @@ export const useQuery = <Client extends ApiClient, Options extends ApiOptions, D
: {})
})
}

/**
* A hook for SCAPI custom endpoint queries.
*
* Besides calling custom endpoint, this hook does a few things for better DX.
* 1. inject access token
* 2. merge SCAPI client configurations from the CommerceApiProvider
* @param apiOptions - Options passed through to commerce-sdk-isomorphic
* @param queryOptions - Options passed through to @tanstack/react-query
* @returns A TanStack Query query hook with data from the custom API endpoint.
*/
export const useCustomQuery = (
apiOptions: OptionalCustomEndpointClientConfig,
queryOptions?: UseQueryOptions<unknown, unknown, unknown, any>
) => {
const config = useConfig()
const auth = useAuthContext()
const callCustomEndpointWithAuth = (options: OptionalCustomEndpointClientConfig) => {
const clientConfig = options.clientConfig || {}
const clientHeaders = config.headers || {}
return async () => {
const {access_token} = await auth.ready()
return await helpers.callCustomEndpoint({
...options,
options: {
method: options.options?.method || 'GET',
headers: {
Authorization: `Bearer ${access_token}`,
...clientHeaders,
...options.options?.headers
},
...options.options
},
clientConfig: {
parameters: {
clientId: config.clientId,
siteId: config.siteId,
organizationId: config.organizationId,
shortCode: config.organizationId
},
proxy: config.proxy,
...clientConfig
}
})
}
}

if (
!apiOptions.options.customApiPathParameters ||
!apiOptions.options.customApiPathParameters.apiName ||
!apiOptions.options.customApiPathParameters.apiVersion ||
!apiOptions.options.customApiPathParameters.endpointPath
) {
throw new Error('options.customApiPathParameters are required for useCustomQuery')
}

// Following the query key convention in this repo, the first element of the query key is a static prefix
// the following elements are the path components of the endpoint
const queryKey = [
'/commerce-sdk-react',
'/custom',
`/${apiOptions.options.customApiPathParameters.apiName}`,
`/${apiOptions.options.customApiPathParameters.apiVersion}`,
`/organizations`,
`/${apiOptions.options.customApiPathParameters.organizationId || config.organizationId}`,
`/${apiOptions.options.customApiPathParameters.endpointPath}`,
{...apiOptions.options.parameters}
]
return useReactQuery(queryKey, callCustomEndpointWithAuth(apiOptions), queryOptions)
}
Loading

0 comments on commit dcfae70

Please sign in to comment.