Skip to content

Commit

Permalink
Merge pull request #1233 from nickgros/PORTALS-2923b
Browse files Browse the repository at this point in the history
  • Loading branch information
nickgros authored Sep 24, 2024
2 parents 9eabcbd + 755c94d commit 7126c3c
Show file tree
Hide file tree
Showing 13 changed files with 314 additions and 47 deletions.
1 change: 1 addition & 0 deletions packages/synapse-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@vitest/ui": "^1.6.0",
"lodash-es": "^4.17.21",
"minimist": "^1.2.8",
"msw": "^2.4.9",
"rimraf": "^5.0.5",
"tslib": "^2.6.2",
"tsx": "^4.19.1",
Expand Down
102 changes: 102 additions & 0 deletions packages/synapse-client/src/SynapseClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { http, HttpResponse } from 'msw'
import { ErrorResponse } from './generated/index'
import { FileEntity } from './generated/models/FileEntity'
import { server } from './mocks/node'
import { SynapseClient } from './SynapseClient'
import { fetchResponseWithExponentialTimeout } from './util/fetchWithExponentialTimeout'
import { SynapseClientError } from './util/SynapseClientError'

vi.mock('./util/fetchWithExponentialTimeout', async importOriginal => {
const original = await importOriginal<
typeof import('./util/fetchWithExponentialTimeout')
>()
return {
...original,
fetchResponseWithExponentialTimeout: vi
.fn()
.mockImplementation(original.fetchResponseWithExponentialTimeout),
}
})

const fetchResponseWithExponentialTimeoutSpy = vi.mocked(
fetchResponseWithExponentialTimeout,
)

describe('SynapseClient', () => {
it('Should use fetchWithExponentialTimeout as the default fetchApi', async () => {
// Set up mock service worker
const expectedResponse: FileEntity = {
id: 'syn123',
concreteType: 'org.sagebionetworks.repo.model.FileEntity',
}
server.use(
http.get('https://repo-prod.prod.sagebase.org/repo/v1/entity/:id', () => {
return HttpResponse.json(expectedResponse)
}),
)

const client = new SynapseClient()
const actual = await client.entityServicesClient.getRepoV1EntityId({
id: 'syn123',
})

expect(actual).toEqual(expectedResponse)

// verify fetchApi is used
expect(fetchResponseWithExponentialTimeoutSpy).toHaveBeenCalled()
})

it('allows overriding the base path', async () => {
const mockBasePath = 'https://repo-mock.mock.sagebase.org'
// Set up mock service worker
const expectedResponse: FileEntity = {
id: 'syn456',
concreteType: 'org.sagebionetworks.repo.model.FileEntity',
}
server.use(
http.get(`${mockBasePath}/repo/v1/entity/:id`, () => {
return HttpResponse.json(expectedResponse)
}),
)

const client = new SynapseClient({ basePath: mockBasePath })
const actual = await client.entityServicesClient.getRepoV1EntityId({
id: 'syn456',
})

expect(actual).toEqual(expectedResponse)

// verify fetchApi is used
expect(fetchResponseWithExponentialTimeoutSpy).toHaveBeenCalled()
})

it('Should throw SynapseClientError on a 400-level error', async () => {
const response: ErrorResponse = {
reason: 'Not found!',
concreteType: 'org.sagebionetworks.repo.model.ErrorResponse',
}
server.use(
http.get('https://repo-prod.prod.sagebase.org/repo/v1/entity/:id', () => {
return HttpResponse.json(response, { status: 404 })
}),
)
const client = new SynapseClient()

let error: SynapseClientError = new SynapseClientError(500, '', '')
try {
await client.entityServicesClient.getRepoV1EntityId({
id: 'syn123',
})
} catch (e) {
error = e as SynapseClientError
}

expect(error).toBeInstanceOf(SynapseClientError)
expect(error.status).toBe(404)
expect(error.reason).toBe('Not found!')
expect(error.errorResponse).toEqual(response)
expect(error.url).toBe(
'https://repo-prod.prod.sagebase.org/repo/v1/entity/syn123',
)
})
})
68 changes: 56 additions & 12 deletions packages/synapse-client/src/SynapseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,63 @@ import { VerificationServicesApi } from './generated/apis/VerificationServicesAp
import { WebhookServicesApi } from './generated/apis/WebhookServicesApi'
import { WikiPageServices2Api } from './generated/apis/WikiPageServices2Api'
import { WikiPageServicesApi } from './generated/apis/WikiPageServicesApi'
import { BaseAPI, Configuration } from './generated/runtime'
import { fetchWithExponentialTimeout } from './util/fetchWithExponentialTimeout'
import { ErrorResponse } from './generated/models/ErrorResponse'
import {
Configuration,
ConfigurationParameters,
ErrorContext,
ResponseContext,
} from './generated/runtime'
import { fetchResponseWithExponentialTimeout } from './util/fetchWithExponentialTimeout'
import { NETWORK_UNAVAILABLE_MESSAGE } from './util/Constants'
import { SynapseClientError } from './util/SynapseClientError'

const DEFAULT_CONFIG = new Configuration({
fetchApi: fetchWithExponentialTimeout,
})
const DEFAULT_CONFIG_PARAMETERS: ConfigurationParameters = {
fetchApi: fetchResponseWithExponentialTimeout,
middleware: [
{
async post(context: ResponseContext): Promise<Response | void> {
const { response, url } = context
if (!response.ok) {
const error = await response.json()
if (
error !== null &&
typeof error === 'object' &&
'reason' in error
) {
throw new SynapseClientError(
response.status,
(error as ErrorResponse).reason!,
url,
error as ErrorResponse,
)
} else {
throw new SynapseClientError(
response.status,
JSON.stringify(error),
url,
)
}
}
},
// Convert error objects to our SynapseClientError
onError(context: ErrorContext): Promise<Response | void> {
const { response, error } = context
console.error(error)
throw new SynapseClientError(
0,
NETWORK_UNAVAILABLE_MESSAGE,
response?.url!,
)
},
},
],
}

/**
* Creates one class that encapsulates all sets of Synapse API services.
*/
export class SynapseClient extends BaseAPI {
export class SynapseClient {
public accessApprovalServicesClient: AccessApprovalServicesApi
public accessRequirementServicesClient: AccessRequirementServicesApi
public activityServicesClient: ActivityServicesApi
Expand Down Expand Up @@ -96,15 +142,13 @@ export class SynapseClient extends BaseAPI {
public wikiPageServices2Client: WikiPageServices2Api
public wikiPageServicesClient: WikiPageServicesApi

constructor(protected configuration = DEFAULT_CONFIG) {
constructor(configurationParameters?: ConfigurationParameters) {
// Merge the default configuration with the provided configuration
configuration = new Configuration({
...DEFAULT_CONFIG.config,
...configuration.config,
const configuration = new Configuration({
...DEFAULT_CONFIG_PARAMETERS,
...configurationParameters,
})

super(configuration)

this.accessApprovalServicesClient = new AccessApprovalServicesApi(
configuration,
)
Expand Down
3 changes: 3 additions & 0 deletions packages/synapse-client/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const handlers = [
// shared handlers go here
]
4 changes: 4 additions & 0 deletions packages/synapse-client/src/mocks/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
44 changes: 32 additions & 12 deletions packages/synapse-client/src/util/fetchWithExponentialTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,19 @@ export function delay(t: number) {
const RETRY_STATUS_CODES = [0, 429, 502, 503, 504]
const MAX_RETRY_STATUS_CODES = [502, 503]
const MAX_RETRY = 3

/**
* Fetches data, retrying if the HTTP status code indicates that it could be retried. Contains custom logic for
* handling errors returned by the Synapse backend.
* Fetches data, retrying if the HTTP status code indicates that it could be retried.
* To use it in our generated client, this function must NOT consume the response body.
*
* @throws SynapseClientError
*/
export const fetchWithExponentialTimeout = async <TResponse>(
export const fetchResponseWithExponentialTimeout = async (
requestInfo: RequestInfo,
options: RequestInit,
delayMs = 1000,
): Promise<TResponse> => {
const url = typeof requestInfo === 'string' ? requestInfo : requestInfo.url
let response
try {
response = await fetch(requestInfo, options)
} catch (err) {
console.error(err)
throw new SynapseClientError(0, NETWORK_UNAVAILABLE_MESSAGE, url)
}
) => {
let response = await fetch(requestInfo, options)

let numOfTry = 1
while (response.status && RETRY_STATUS_CODES.includes(response.status)) {
Expand All @@ -58,6 +53,31 @@ export const fetchWithExponentialTimeout = async <TResponse>(
}
}

return response
}

/**
* Fetches data, retrying if the HTTP status code indicates that it could be retried. Contains custom logic for
* handling errors returned by the Synapse backend.
* @throws SynapseClientError
*/
export const fetchWithExponentialTimeout = async <TResponse>(
requestInfo: RequestInfo,
options: RequestInit,
delayMs = 1000,
): Promise<TResponse> => {
const url = typeof requestInfo === 'string' ? requestInfo : requestInfo.url
let response
try {
response = await fetchResponseWithExponentialTimeout(
requestInfo,
options,
delayMs,
)
} catch (err) {
console.error(err)
throw new SynapseClientError(0, NETWORK_UNAVAILABLE_MESSAGE, url)
}
const contentType = response.headers.get('Content-Type')
const responseBody = await response.text()
let responseObject: TResponse | BaseError | string = responseBody
Expand Down
6 changes: 6 additions & 0 deletions packages/synapse-client/src/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/node'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
7 changes: 6 additions & 1 deletion packages/synapse-client/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"noEmit": false,
"types": ["vitest/globals"]
},
"include": ["src/*.ts", "src/generated/**/*.ts", "src/util/**/*.ts"],
"include": [
"src/index.ts",
"src/SynapseClient.ts",
"src/generated/**/*.ts",
"src/util/**/*.ts"
],
"exclude": ["src/**/*.test.ts"]
}
9 changes: 8 additions & 1 deletion packages/synapse-client/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { ConfigBuilder } from 'vite-config'

const config = new ConfigBuilder().setIncludeVitestConfig(true).build()
const config = new ConfigBuilder()
.setIncludeVitestConfig(true)
.setConfigOverrides({
test: {
setupFiles: ['./src/vitest.setup.ts'],
},
})
.build()

export default config
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { createWrapper } from '../../testutils/TestingLibraryUtils'
Expand Down Expand Up @@ -33,8 +33,10 @@ describe('SharePageLinkButton', () => {
await userEvent.click(
screen.getByRole('button', { name: 'Share Page Link' }),
)
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
MOCK_SHORT_IO_URL,
)
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
MOCK_SHORT_IO_URL,
)
})
})
})
15 changes: 6 additions & 9 deletions packages/synapse-react-client/src/mocks/MockSynapseContext.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { SynapseClient } from '@sage-bionetworks/synapse-client/SynapseClient'
import React from 'react'
import { SynapseContextType } from '../utils/context/SynapseContext'
import FullContextProvider from '../utils/context/FullContextProvider'
import { KeyFactory } from '../synapse-queries'
import { Configuration } from '@sage-bionetworks/synapse-client/generated/runtime'
import { SynapseClient } from '@sage-bionetworks/synapse-client/SynapseClient'
import FullContextProvider from '../utils/context/FullContextProvider'
import { SynapseContextType } from '../utils/context/SynapseContext'

export const MOCK_ACCESS_TOKEN = 'mock-access-token'

Expand All @@ -14,11 +13,9 @@ export const MOCK_CONTEXT_VALUE: SynapseContextType = {
downloadCartPageUrl: '/DownloadCart',
withErrorBoundary: false,
keyFactory: new KeyFactory(MOCK_ACCESS_TOKEN),
synapseClient: new SynapseClient(
new Configuration({
apiKey: `Bearer ${MOCK_ACCESS_TOKEN}`,
}),
),
synapseClient: new SynapseClient({
accessToken: MOCK_ACCESS_TOKEN,
}),
}

export const MOCK_CONTEXT = React.createContext(MOCK_CONTEXT_VALUE)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { SynapseClient } from '@sage-bionetworks/synapse-client/SynapseClient'
import React, { useContext, useMemo } from 'react'
import { SynapseErrorBoundary } from '../../components/error/ErrorBanner'
import { KeyFactory } from '../../synapse-queries/KeyFactory'
import { Configuration } from '@sage-bionetworks/synapse-client/generated/runtime'
import { SynapseClient } from '@sage-bionetworks/synapse-client/SynapseClient'
import { BackendDestinationEnum, getEndpoint } from '../functions/getEndpoint'

export type SynapseContextType = {
/** The user's access token. If undefined, the user is not logged in */
Expand Down Expand Up @@ -61,12 +61,11 @@ export function SynapseContextProvider(props: SynapseContextProviderProps) {
if (providedContext.synapseClient) {
return providedContext.synapseClient
}
const configuration = new Configuration({
apiKey: providedContext.accessToken
? `Bearer ${providedContext.accessToken}`
: undefined,
})
return new SynapseClient(configuration)
const configurationParameters = {
accessToken: providedContext.accessToken,
basePath: getEndpoint(BackendDestinationEnum.REPO_ENDPOINT),
}
return new SynapseClient(configurationParameters)
}, [providedContext.synapseClient, providedContext.accessToken])

const synapseContext: SynapseContextType = useMemo(
Expand Down
Loading

0 comments on commit 7126c3c

Please sign in to comment.