Skip to content

Commit

Permalink
feat: replace axios with fetch in js client sdk (#739)
Browse files Browse the repository at this point in the history
  • Loading branch information
elliotCamblor authored Feb 23, 2024
1 parent 735c575 commit 7d59ff8
Show file tree
Hide file tree
Showing 17 changed files with 372 additions and 230 deletions.
29 changes: 27 additions & 2 deletions lib/shared/server-request/src/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// NOTE: This file is duplicated in "sdk/js/src/RequestUtils" because nx:rollup cant build non-external dependencies
// from outside the root directory https://github.com/nrwl/nx/issues/10395

import fetchWithRetry, { RequestInitWithRetry } from 'fetch-retry'

export class ResponseError extends Error {
Expand All @@ -9,7 +12,9 @@ export class ResponseError extends Error {
status: number
}

const exponentialBackoff: RequestInitWithRetry['retryDelay'] = (attempt) => {
export const exponentialBackoff: RequestInitWithRetry['retryDelay'] = (
attempt,
) => {
const delay = Math.pow(2, attempt) * 100
const randomSum = delay * 0.2 * Math.random()
return delay + randomSum
Expand All @@ -31,7 +36,7 @@ const retryOnRequestError: retryOnRequestErrorFunc = (retries) => {
}
}

const handleResponse = async (res: Response) => {
export async function handleResponse(res: Response): Promise<Response> {
// res.ok only checks for 200-299 status codes
if (!res.ok && res.status >= 400) {
let error
Expand Down Expand Up @@ -88,6 +93,26 @@ export async function post(
return handleResponse(res)
}

export async function patch(
url: string,
requestConfig: RequestInit | RequestInitWithRetry,
sdkKey: string,
): Promise<Response> {
const [_fetch, config] = await getFetchAndConfig(requestConfig)
const patchHeaders = {
...config.headers,
Authorization: sdkKey,
'Content-Type': 'application/json',
}

const res = await _fetch(url, {
...config,
headers: patchHeaders,
method: 'PATCH',
})

return handleResponse(res)
}
export async function get(
url: string,
requestConfig: RequestInit | RequestInitWithRetry,
Expand Down
3 changes: 3 additions & 0 deletions nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"pluginsConfig": {
"@nx/js": {
"analyzeSourceFiles": true
},
"@nrwl/rollup": {
"analyzeSourceFiles": true
}
},
"namedInputs": {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@
"class-transformer": "0.5.1",
"class-validator": "^0.14.1",
"core-js": "^3.6.5",
"cross-fetch": "^3.1.8",
"cross-fetch": "^4.0.0",
"eslint-plugin-lodash": "^7.4.0",
"fetch-retry": "^5.0.3",
"fetch-retry": "^5.0.6",
"hoist-non-react-statics": "^3.3.2",
"iso-639-1": "^2.1.13",
"jira-prepare-commit-msg": "^1.6.2",
Expand Down
4 changes: 2 additions & 2 deletions sdk/js-cloud-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"license": "MIT",
"dependencies": {
"@devcycle/types": "^1.9.0",
"cross-fetch": "^3.1.8",
"fetch-retry": "^5.0.3",
"cross-fetch": "^4.0.0",
"fetch-retry": "^5.0.6",
"lodash": "^4.17.21"
},
"main": "src/index.js",
Expand Down
6 changes: 6 additions & 0 deletions sdk/js/__mocks__/cross-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const { Request, Response } = jest.requireActual('cross-fetch')

const fetch = jest.fn()

export { Request, Response }
export default fetch
2 changes: 2 additions & 0 deletions sdk/js/__mocks__/fetch-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const fetchWithRetry = (_fetch: unknown): unknown => _fetch
export default fetchWithRetry
11 changes: 6 additions & 5 deletions sdk/js/__tests__/Client.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { DevCycleClient } from '../src/Client'
jest.mock('../src/Request')
jest.mock('../src/StreamingConnection')

jest.unmock('cross-fetch')
import fetch from 'cross-fetch'
global.fetch = fetch
type Variables = {
enum_var: 'value1' | 'value2'
bool: boolean
string: string
number: number
}

jest.mock('fetch-retry')
import { DevCycleClient } from '../src/Client'
jest.mock('../src/StreamingConnection')
describe('DevCycleClient', () => {
it('should prevent invalid variables', () => {
const client = new DevCycleClient<Variables>('test', {
Expand Down
161 changes: 69 additions & 92 deletions sdk/js/__tests__/Request.spec.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,28 @@
import { DVCPopulatedUser } from '../src/User'

jest.mock('axios')
import axios, { AxiosInstance } from 'axios'
import { mocked } from 'jest-mock'

const axiosRequestMock = jest.fn()
const createMock = mocked(axios.create)

createMock.mockImplementation((): AxiosInstance => {
return {
request: axiosRequestMock,
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() },
},
} as unknown as AxiosInstance
})
jest.mock('cross-fetch')
import fetch, { Response } from 'cross-fetch'

global.fetch = fetch
import { DVCPopulatedUser } from '../src/User'
import * as Request from '../src/Request'
import { BucketedUserConfig } from '@devcycle/types'
import { dvcDefaultLogger } from '../src/logger'

const defaultLogger = dvcDefaultLogger({ level: 'debug' })
const fetchRequestMock = fetch as jest.MockedFn<typeof fetch>

describe('Request tests', () => {
beforeEach(() => {
axiosRequestMock.mockReset()
})

describe('baseRequestParams', () => {
const { baseRequestHeaders } = Request
it('should add sdkKey header if passed in', () => {
const params = baseRequestHeaders('my_sdk_key')
expect(params['Content-Type']).toBe('application/json')
expect(params['Authorization']).toBe('my_sdk_key')
})

it('should not add header if no sdkKey passed in', () => {
const params = baseRequestHeaders()
expect(params['Content-Type']).toBe('application/json')
expect(params['Authorization']).toBeUndefined()
})
fetchRequestMock.mockClear()
fetchRequestMock.mockResolvedValue(
new Response(JSON.stringify({}), {
status: 200,
}),
)
})

describe('getConfigJson', () => {
it('should call get with serialized user and SDK key in params', async () => {
const user = { user_id: 'my_user', isAnonymous: false }
const sdkKey = 'my_sdk_key'
axiosRequestMock.mockResolvedValue({ status: 200, data: {} })

await Request.getConfigJson(
sdkKey,
user as DVCPopulatedUser,
Expand All @@ -61,35 +35,35 @@ describe('Request tests', () => {
},
)

expect(axiosRequestMock).toBeCalledWith({
headers: { 'Content-Type': 'application/json' },
method: 'GET',
url:
'https://sdk-api.devcycle.com/v1/sdkConfig?sdkKey=' +
expect(fetchRequestMock).toBeCalledWith(
'https://sdk-api.devcycle.com/v1/sdkConfig?sdkKey=' +
`${sdkKey}&user_id=${user.user_id}&isAnonymous=false&sse=1&sseLastModified=1234&sseEtag=etag`,
})
expect.objectContaining({
headers: { 'Content-Type': 'application/json' },
method: 'GET',
}),
)
})

it('should call local proxy for apiProxyURL option', async () => {
const user = { user_id: 'my_user', isAnonymous: false }
const sdkKey = 'my_sdk_key'
const dvcOptions = { apiProxyURL: 'http://localhost:4000' }
axiosRequestMock.mockResolvedValue({ status: 200, data: {} })

await Request.getConfigJson(
sdkKey,
user as DVCPopulatedUser,
defaultLogger,
dvcOptions,
)

expect(axiosRequestMock).toBeCalledWith({
headers: { 'Content-Type': 'application/json' },
method: 'GET',
url:
`${dvcOptions.apiProxyURL}/v1/sdkConfig?sdkKey=` +
expect(fetchRequestMock).toBeCalledWith(
`${dvcOptions.apiProxyURL}/v1/sdkConfig?sdkKey=` +
`${sdkKey}&user_id=${user.user_id}&isAnonymous=false`,
})
expect.objectContaining({
headers: { 'Content-Type': 'application/json' },
method: 'GET',
}),
)
})
})

Expand All @@ -99,10 +73,11 @@ describe('Request tests', () => {
const config = {} as BucketedUserConfig
const sdkKey = 'my_sdk_key'
const events = [{ type: 'event_1_type' }, { type: 'event_2_type' }]
axiosRequestMock.mockResolvedValue({
status: 200,
data: 'messages',
})
fetchRequestMock.mockResolvedValue(
new Response('{}', {
status: 200,
}),
)

await Request.publishEvents(
sdkKey,
Expand All @@ -112,30 +87,25 @@ describe('Request tests', () => {
defaultLogger,
)

expect(axiosRequestMock).toBeCalledWith({
headers: {
Authorization: 'my_sdk_key',
'Content-Type': 'application/json',
},
method: 'POST',
url: 'https://events.devcycle.com/v1/events',
data: {
events: [
expect.objectContaining({
customType: 'event_1_type',
type: 'customEvent',
user_id: 'my_user',
clientDate: expect.any(Number),
}),
expect.objectContaining({
customType: 'event_2_type',
type: 'customEvent',
user_id: 'my_user',
clientDate: expect.any(Number),
}),
],
user,
},
const call = fetchRequestMock.mock.calls[0]
const requestBody = JSON.parse(call[1]?.body as string)

expect(requestBody).toEqual({
events: [
expect.objectContaining({
customType: 'event_1_type',
type: 'customEvent',
user_id: 'my_user',
clientDate: expect.any(Number),
}),
expect.objectContaining({
customType: 'event_2_type',
type: 'customEvent',
user_id: 'my_user',
clientDate: expect.any(Number),
}),
],
user,
})
})
})
Expand All @@ -144,26 +114,33 @@ describe('Request tests', () => {
it('should send user data to edgedb api with url-encoded id', async () => {
const user = { user_id: '[email protected]', isAnonymous: false }
const sdkKey = 'my_sdk_key'
axiosRequestMock.mockResolvedValue({ status: 200, data: {} })
fetchRequestMock.mockResolvedValue(
new Response('{}', {
status: 200,
}),
)

await Request.saveEntity(
user as DVCPopulatedUser,
sdkKey,
defaultLogger,
)

expect(axiosRequestMock).toBeCalledWith({
headers: {
'Content-Type': 'application/json',
Authorization: 'my_sdk_key',
},
data: {
user_id: '[email protected]',
isAnonymous: false,
},
method: 'PATCH',
url: 'https://sdk-api.devcycle.com/v1/edgedb/user%40example.com',
const call = fetchRequestMock.mock.calls[0]
const requestBody = JSON.parse(call[1]?.body as string)
expect(requestBody).toEqual({
user_id: '[email protected]',
isAnonymous: false,
})
expect(fetchRequestMock).toBeCalledWith(
'https://sdk-api.devcycle.com/v1/edgedb/user%40example.com',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: 'my_sdk_key',
},
method: 'PATCH',
}),
)
})
})
})
1 change: 0 additions & 1 deletion sdk/js/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
export default {
displayName: 'js-client-sdk',

globals: {},
transform: {
'^.+\\.(ts|tsx|js|jsx)?$': [
Expand Down
4 changes: 2 additions & 2 deletions sdk/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"types": "./index.cjs.d.ts",
"dependencies": {
"@devcycle/types": "^1.9.0",
"axios": "^1.0.0",
"axios-retry": "^3.3.1",
"cross-fetch": "^4.0.0",
"fetch-retry": "^5.0.6",
"lodash": "^4.17.21",
"ua-parser-js": "^1.0.36",
"uuid": "^8.3.2"
Expand Down
Loading

0 comments on commit 7d59ff8

Please sign in to comment.