Skip to content

Commit c8650e3

Browse files
authored
feat (datafile manager)[OASIS-4310]: Browser datafile manager (#6)
Summary: Add `BrowserDatafileManager`, similar to `NodeDatafileManager`: a subclass of `HttpPollingDatafileManager` implementing `makeGetRequest` using `XMLHttpRequest`. The caching described in the design is not yet implemented. Test plan: Unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4310
1 parent 8cbd16c commit c8650e3

File tree

6 files changed

+429
-2
lines changed

6 files changed

+429
-2
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Copyright 2019, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import BrowserDatafileManager from '../src/browserDatafileManager'
18+
import * as browserRequest from '../src/browserRequest'
19+
import { Headers, AbortableRequest } from '../src/http'
20+
import TestTimeoutFactory from './testTimeoutFactory'
21+
22+
describe('browserDatafileManager', () => {
23+
const testTimeoutFactory: TestTimeoutFactory = new TestTimeoutFactory()
24+
25+
let makeGetRequestSpy: jest.SpyInstance<AbortableRequest, [string, Headers]>
26+
beforeEach(() => {
27+
makeGetRequestSpy = jest.spyOn(browserRequest, 'makeGetRequest')
28+
})
29+
30+
afterEach(() => {
31+
jest.restoreAllMocks()
32+
testTimeoutFactory.cleanup()
33+
})
34+
35+
it('calls makeGetRequest when started', async () => {
36+
makeGetRequestSpy.mockReturnValue({
37+
abort: jest.fn(),
38+
responsePromise: Promise.resolve({
39+
statusCode: 200,
40+
body: '{"foo":"bar"}',
41+
headers: {},
42+
})
43+
})
44+
45+
const manager = new BrowserDatafileManager({
46+
sdkKey: '1234',
47+
liveUpdates: false,
48+
})
49+
manager.start()
50+
expect(makeGetRequestSpy).toBeCalledTimes(1)
51+
expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json')
52+
expect(makeGetRequestSpy.mock.calls[0][1]).toEqual({})
53+
54+
await manager.onReady()
55+
await manager.stop()
56+
})
57+
58+
it('calls makeGetRequest for live update requests', async () => {
59+
makeGetRequestSpy.mockReturnValue({
60+
abort: jest.fn(),
61+
responsePromise: Promise.resolve({
62+
statusCode: 200,
63+
body: '{"foo":"bar"}',
64+
headers: {
65+
'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT',
66+
},
67+
})
68+
})
69+
const manager = new BrowserDatafileManager({
70+
sdkKey: '1234',
71+
liveUpdates: true,
72+
timeoutFactory: testTimeoutFactory,
73+
})
74+
manager.start()
75+
await manager.onReady()
76+
testTimeoutFactory.timeoutFns[0]()
77+
expect(makeGetRequestSpy).toBeCalledTimes(2)
78+
expect(makeGetRequestSpy.mock.calls[1][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json')
79+
expect(makeGetRequestSpy.mock.calls[1][1]).toEqual({
80+
'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT'
81+
})
82+
83+
await manager.stop()
84+
})
85+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
// TODO: It doesn't work unless the jest-enviroment comment is at the top...
6+
// ...so if we need the license header at the top, we have to fix this
7+
8+
/**
9+
* Copyright 2019, Optimizely
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*/
23+
24+
import {
25+
SinonFakeXMLHttpRequest,
26+
SinonFakeXMLHttpRequestStatic,
27+
useFakeXMLHttpRequest,
28+
} from 'sinon'
29+
import { makeGetRequest } from '../src/browserRequest'
30+
31+
describe('browserRequest', () => {
32+
describe('makeGetRequest', () => {
33+
let mockXHR: SinonFakeXMLHttpRequestStatic
34+
let xhrs: SinonFakeXMLHttpRequest[]
35+
beforeEach(() => {
36+
xhrs = []
37+
mockXHR = useFakeXMLHttpRequest()
38+
mockXHR.onCreate = req => xhrs.push(req)
39+
})
40+
41+
afterEach(() => {
42+
mockXHR.restore()
43+
})
44+
45+
it('makes a GET request to the argument URL', async () => {
46+
const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {})
47+
48+
expect(xhrs.length).toBe(1)
49+
const xhr = xhrs[0]
50+
const { url, method } = xhr
51+
expect({ url, method }).toEqual({
52+
url: 'https://cdn.optimizely.com/datafiles/123.json',
53+
method: 'GET',
54+
})
55+
56+
xhr.respond(200, {}, '{"foo":"bar"}')
57+
58+
await req.responsePromise
59+
})
60+
61+
it('returns a 200 response back to its superclass', async () => {
62+
const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {})
63+
64+
const xhr = xhrs[0]
65+
xhr.respond(200, {}, '{"foo":"bar"}')
66+
67+
const resp = await req.responsePromise
68+
expect(resp).toEqual({
69+
statusCode: 200,
70+
headers: {},
71+
body: '{"foo":"bar"}',
72+
})
73+
})
74+
75+
it('returns a 404 response back to its superclass', async () => {
76+
const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {})
77+
78+
const xhr = xhrs[0]
79+
xhr.respond(404, {}, '')
80+
81+
const resp = await req.responsePromise
82+
expect(resp).toEqual({
83+
statusCode: 404,
84+
headers: {},
85+
body: '',
86+
})
87+
})
88+
89+
it('includes headers from the headers argument in the request', async () => {
90+
const req = makeGetRequest('https://cdn.optimizely.com/dataifles/123.json', {
91+
'if-modified-since': 'Fri, 08 Mar 2019 18:57:18 GMT',
92+
})
93+
94+
expect(xhrs.length).toBe(1)
95+
expect(xhrs[0].requestHeaders['if-modified-since']).toBe(
96+
'Fri, 08 Mar 2019 18:57:18 GMT',
97+
)
98+
99+
xhrs[0].respond(404, {}, '')
100+
101+
await req.responsePromise
102+
})
103+
104+
it('includes headers from the response in the eventual response in the return value', async () => {
105+
const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {})
106+
107+
const xhr = xhrs[0]
108+
xhr.respond(
109+
200,
110+
{
111+
'content-type': 'application/json',
112+
'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT',
113+
},
114+
'{"foo":"bar"}',
115+
)
116+
117+
const resp = await req.responsePromise
118+
expect(resp).toEqual({
119+
statusCode: 200,
120+
body: '{"foo":"bar"}',
121+
headers: {
122+
'content-type': 'application/json',
123+
'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT',
124+
},
125+
})
126+
})
127+
128+
it('returns a rejected promise when there is a request error', async () => {
129+
const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {})
130+
xhrs[0].error()
131+
await expect(req.responsePromise).rejects.toThrow()
132+
})
133+
})
134+
})

packages/datafile-manager/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
"devDependencies": {
3434
"@types/jest": "^24.0.9",
3535
"@types/nock": "^9.3.1",
36+
"@types/sinon": "^7.0.10",
3637
"jest": "^24.1.0",
3738
"nock": "^10.0.6",
39+
"sinon": "^7.2.7",
3840
"ts-jest": "^24.0.0",
3941
"typescript": "^3.3.3333"
4042
},
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright 2019, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { makeGetRequest } from './browserRequest'
18+
import HttpPollingDatafileManager from './httpPollingDatafileManager'
19+
import { Headers, AbortableRequest } from './http';
20+
21+
export default class BrowserDatafileManager extends HttpPollingDatafileManager {
22+
protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest {
23+
return makeGetRequest(reqUrl, headers)
24+
}
25+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Copyright 2019, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { AbortableRequest, Response, Headers } from './http'
18+
19+
const GET_METHOD = 'GET'
20+
const READY_STATE_DONE = 4
21+
22+
function parseHeadersFromXhr(req: XMLHttpRequest): Headers {
23+
const allHeadersString = req.getAllResponseHeaders()
24+
25+
if (allHeadersString === null) {
26+
return {}
27+
}
28+
29+
const headerLines = allHeadersString.split('\r\n')
30+
const headers: Headers = {}
31+
headerLines.forEach(headerLine => {
32+
const separatorIndex = headerLine.indexOf(': ')
33+
if (separatorIndex > -1) {
34+
const headerName = headerLine.slice(0, separatorIndex)
35+
const headerValue = headerLine.slice(separatorIndex + 2)
36+
if (headerValue.length > 0) {
37+
headers[headerName] = headerValue
38+
}
39+
}
40+
})
41+
return headers
42+
}
43+
44+
function setHeadersInXhr(headers: Headers, req: XMLHttpRequest): void {
45+
Object.keys(headers).forEach(headerName => {
46+
const header = headers[headerName]
47+
req.setRequestHeader(headerName, header!)
48+
})
49+
}
50+
51+
export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest {
52+
const req = new XMLHttpRequest()
53+
54+
const responsePromise: Promise<Response> = new Promise((resolve, reject) => {
55+
req.open(GET_METHOD, reqUrl, true)
56+
57+
setHeadersInXhr(headers, req)
58+
59+
req.onreadystatechange = () => {
60+
if (req.readyState === READY_STATE_DONE) {
61+
const statusCode = req.status
62+
if (statusCode === 0) {
63+
reject(new Error('Request error'))
64+
return
65+
}
66+
67+
const headers = parseHeadersFromXhr(req)
68+
const resp: Response = {
69+
statusCode: req.status,
70+
body: req.responseText,
71+
headers,
72+
}
73+
resolve(resp)
74+
}
75+
}
76+
77+
req.send()
78+
})
79+
80+
return {
81+
responsePromise,
82+
abort() {
83+
req.abort()
84+
},
85+
}
86+
}

0 commit comments

Comments
 (0)