Skip to content

Commit 7d3d6a1

Browse files
authored
refactor: XRequest to support custom protocols (#293)
* refactor: XRequest to support custom protocols. * docs: add demo * test: update snapshots * chore: update bun.lockb * test: refactor the test cases for XRequest * refactor: improve the Content-Type matching * test: add test for same instance for the same baseURL or fetch * test: add test for XFetch * chore: prettier code * test: add test for XRequest 1.0.2 * docs: refactor ts * test: mod x-request test file name * test: refactor test case and supplemental coverage * test: add test case for TransformStream error
1 parent 9ff72b9 commit 7d3d6a1

File tree

10 files changed

+419
-140
lines changed

10 files changed

+419
-140
lines changed

bun.lockb

117 Bytes
Binary file not shown.

components/x-request/__tests__/__snapshots__/demo.test.ts.snap

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
44
<div
5-
class="ant-splitter ant-splitter-horizontal"
5+
class="ant-space ant-space-horizontal ant-space-align-start"
6+
style="column-gap:16px;row-gap:16px"
67
>
78
<div
8-
class="ant-splitter-panel"
9-
style="flex-basis:auto;flex-grow:1"
9+
class="ant-space-item"
1010
>
1111
<button
1212
class="ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"
@@ -18,23 +18,10 @@ exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
1818
</button>
1919
</div>
2020
<div
21-
aria-valuemax="0"
22-
aria-valuemin="0"
23-
aria-valuenow="50"
24-
class="ant-splitter-bar"
25-
role="separator"
26-
>
27-
<div
28-
class="ant-splitter-bar-dragger"
29-
/>
30-
</div>
31-
<div
32-
class="ant-splitter-panel"
33-
style="flex-basis:auto;flex-grow:1"
21+
class="ant-space-item"
3422
>
3523
<div
3624
class="ant-thought-chain ant-thought-chain-middle"
37-
style="margin-left:16px"
3825
>
3926
<div
4027
class="ant-thought-chain-item"
@@ -148,3 +135,86 @@ exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
148135
</div>
149136
</div>
150137
`;
138+
139+
exports[`renders components/x-request/demo/custom-transformer.tsx correctly 1`] = `
140+
<div
141+
class="ant-space ant-space-horizontal ant-space-align-start"
142+
style="column-gap:16px;row-gap:16px"
143+
>
144+
<div
145+
class="ant-space-item"
146+
>
147+
<button
148+
class="ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"
149+
type="button"
150+
>
151+
<span>
152+
Request - https://api.example.host/chat
153+
</span>
154+
</button>
155+
</div>
156+
<div
157+
class="ant-space-item"
158+
>
159+
<div
160+
class="ant-thought-chain ant-thought-chain-middle"
161+
>
162+
<div
163+
class="ant-thought-chain-item"
164+
>
165+
<div
166+
class="ant-thought-chain-item-header"
167+
>
168+
<span
169+
class="ant-avatar ant-avatar-circle ant-avatar-icon ant-thought-chain-item-icon"
170+
>
171+
<span
172+
aria-label="tags"
173+
class="anticon anticon-tags"
174+
role="img"
175+
>
176+
<svg
177+
aria-hidden="true"
178+
data-icon="tags"
179+
fill="currentColor"
180+
focusable="false"
181+
height="1em"
182+
viewBox="64 64 896 896"
183+
width="1em"
184+
>
185+
<path
186+
d="M483.2 790.3L861.4 412c1.7-1.7 2.5-4 2.3-6.3l-25.5-301.4c-.7-7.8-6.8-13.9-14.6-14.6L522.2 64.3c-2.3-.2-4.7.6-6.3 2.3L137.7 444.8a8.03 8.03 0 000 11.3l334.2 334.2c3.1 3.2 8.2 3.2 11.3 0zm62.6-651.7l224.6 19 19 224.6L477.5 694 233.9 450.5l311.9-311.9zm60.16 186.23a48 48 0 1067.88-67.89 48 48 0 10-67.88 67.89zM889.7 539.8l-39.6-39.5a8.03 8.03 0 00-11.3 0l-362 361.3-237.6-237a8.03 8.03 0 00-11.3 0l-39.6 39.5a8.03 8.03 0 000 11.3l243.2 242.8 39.6 39.5c3.1 3.1 8.2 3.1 11.3 0l407.3-406.6c3.1-3.1 3.1-8.2 0-11.3z"
187+
/>
188+
</svg>
189+
</span>
190+
</span>
191+
<div
192+
class="ant-thought-chain-item-header-box"
193+
>
194+
<span
195+
class="ant-typography ant-typography-ellipsis ant-typography-ellipsis-single-line ant-thought-chain-item-title"
196+
>
197+
<strong>
198+
Mock Custom Protocol - Log
199+
</strong>
200+
</span>
201+
</div>
202+
</div>
203+
<div
204+
class="ant-thought-chain-item-content"
205+
>
206+
<div
207+
class="ant-thought-chain-item-content-box"
208+
>
209+
<pre
210+
style="overflow:scroll"
211+
>
212+
<code />
213+
</pre>
214+
</div>
215+
</div>
216+
</div>
217+
</div>
218+
</div>
219+
</div>
220+
`;
Lines changed: 111 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,46 @@
11
import XRequest from '../index';
22
import xFetch from '../x-fetch';
33

4+
import type { SSEOutput } from '../../x-stream';
5+
import type { XRequestCallbacks, XRequestOptions } from '../index';
6+
47
jest.mock('../x-fetch', () => jest.fn());
58

6-
const sseChunks = ['event:message\ndata:{"id":"0","content":"He"}\n\n'];
9+
const SSE_SEPARATOR = '\n\n';
10+
11+
const ND_JSON_SEPARATOR = '\n';
12+
13+
const sseEvent: SSEOutput = { event: 'message', data: '{"id":"0","content":"He"}' };
14+
15+
const sseData = `${Object.keys(sseEvent)
16+
.map((key) => `${key}:${sseEvent[key as keyof SSEOutput]}`)
17+
.join(ND_JSON_SEPARATOR)}${SSE_SEPARATOR}`;
18+
19+
const ndJsonData = `${JSON.stringify(sseEvent)}${ND_JSON_SEPARATOR}${JSON.stringify({ ...sseEvent, event: 'delta' })}`;
20+
21+
const options: XRequestOptions = {
22+
baseURL: 'https://api.example.com/v1/chat',
23+
model: 'gpt-3.5-turbo',
24+
dangerouslyApiKey: 'dangerouslyApiKey',
25+
};
726

8-
function mockReadableStream() {
27+
const params = { messages: [{ role: 'user', content: 'Hello' }] };
28+
29+
function mockSSEReadableStream() {
930
return new ReadableStream({
1031
async start(controller) {
11-
for (const chunk of sseChunks) {
32+
for (const chunk of sseData.split(SSE_SEPARATOR)) {
33+
controller.enqueue(new TextEncoder().encode(chunk));
34+
}
35+
controller.close();
36+
},
37+
});
38+
}
39+
40+
function mockNdJsonReadableStream() {
41+
return new ReadableStream({
42+
async start(controller) {
43+
for (const chunk of ndJsonData.split(ND_JSON_SEPARATOR)) {
1244
controller.enqueue(new TextEncoder().encode(chunk));
1345
}
1446
controller.close();
@@ -17,109 +49,122 @@ function mockReadableStream() {
1749
}
1850

1951
describe('XRequest Class', () => {
20-
const baseURL = 'https://api.example.com/v1/chat';
21-
const model = 'gpt-3.5-turbo';
52+
const callbacks: XRequestCallbacks<any> = {
53+
onSuccess: jest.fn(),
54+
onError: jest.fn(),
55+
onUpdate: jest.fn(),
56+
};
57+
58+
const mockedXFetch = xFetch as jest.Mock;
59+
60+
let request: ReturnType<typeof XRequest>;
2261

2362
beforeEach(() => {
2463
jest.clearAllMocks();
64+
request = XRequest(options);
2565
});
2666

2767
test('should initialize with valid options', () => {
28-
const request = XRequest({ baseURL, model });
29-
30-
expect(request.baseURL).toBe(baseURL);
31-
expect(request.model).toBe(model);
68+
expect(request.baseURL).toBe(options.baseURL);
69+
expect(request.model).toBe(options.model);
3270
});
3371

3472
test('should throw error on invalid baseURL', () => {
35-
expect(() => XRequest({ baseURL: '', model })).toThrow('The baseURL is not valid!');
73+
expect(() => XRequest({ baseURL: '' })).toThrow('The baseURL is not valid!');
3674
});
3775

3876
test('should create request and handle successful JSON response', async () => {
39-
const onSuccess = jest.fn();
40-
const onError = jest.fn();
41-
const onUpdate = jest.fn();
42-
43-
const params = { messages: [{ role: 'user', content: 'Hello' }] };
44-
45-
const mockedXFetch = xFetch as jest.Mock;
46-
4777
mockedXFetch.mockResolvedValueOnce({
4878
ok: true,
4979
status: 200,
5080
headers: {
51-
get: jest.fn().mockReturnValue('application/json'),
81+
get: jest.fn().mockReturnValue('application/json; charset=utf-8'),
5282
},
53-
json: jest.fn().mockResolvedValueOnce({ response: 'Hi there!' }),
83+
json: jest.fn().mockResolvedValueOnce(params),
5484
});
55-
56-
const request = XRequest({ baseURL, model });
57-
await request.create(params, { onSuccess, onError, onUpdate });
58-
59-
expect(onSuccess).toHaveBeenCalledWith([{ response: 'Hi there!' }]);
60-
expect(onError).not.toHaveBeenCalled();
61-
expect(onUpdate).toHaveBeenCalled();
85+
await request.create(params, callbacks);
86+
expect(callbacks.onSuccess).toHaveBeenCalledWith([params]);
87+
expect(callbacks.onError).not.toHaveBeenCalled();
88+
expect(callbacks.onUpdate).toHaveBeenCalledWith(params);
6289
});
6390

6491
test('should create request and handle streaming response', async () => {
65-
const onSuccess = jest.fn();
66-
const onError = jest.fn();
67-
const onUpdate = jest.fn();
68-
const params = { messages: [{ role: 'user', content: 'Hello' }] };
69-
70-
const mockedXFetch = xFetch as jest.Mock;
7192
mockedXFetch.mockResolvedValueOnce({
7293
headers: {
7394
get: jest.fn().mockReturnValue('text/event-stream'),
7495
},
75-
body: mockReadableStream(),
96+
body: mockSSEReadableStream(),
7697
});
98+
await request.create(params, callbacks);
99+
expect(callbacks.onSuccess).toHaveBeenCalledWith([sseEvent]);
100+
expect(callbacks.onError).not.toHaveBeenCalled();
101+
expect(callbacks.onUpdate).toHaveBeenCalledWith(sseEvent);
102+
});
77103

78-
const request = XRequest({ baseURL, model });
79-
await request.create(params, { onSuccess, onError, onUpdate });
80-
81-
const sseEvent = { event: 'message', data: '{"id":"0","content":"He"}' };
104+
test('should create request and handle custom response, e.g. application/x-ndjson', async () => {
105+
mockedXFetch.mockResolvedValueOnce({
106+
headers: {
107+
get: jest.fn().mockReturnValue('application/x-ndjson'),
108+
},
109+
body: mockNdJsonReadableStream(),
110+
});
111+
await request.create(params, callbacks, new TransformStream());
112+
expect(callbacks.onSuccess).toHaveBeenCalledWith([
113+
ndJsonData.split(ND_JSON_SEPARATOR)[0],
114+
ndJsonData.split(ND_JSON_SEPARATOR)[1],
115+
]);
116+
expect(callbacks.onError).not.toHaveBeenCalled();
117+
expect(callbacks.onUpdate).toHaveBeenCalledWith(ndJsonData.split(ND_JSON_SEPARATOR)[0]);
118+
expect(callbacks.onUpdate).toHaveBeenCalledWith(ndJsonData.split(ND_JSON_SEPARATOR)[1]);
119+
});
82120

83-
expect(onSuccess).toHaveBeenCalledWith([sseEvent]);
84-
expect(onError).not.toHaveBeenCalled();
85-
expect(onUpdate).toHaveBeenCalledWith(sseEvent);
121+
test('should reuse the same instance for the same baseURL or fetch', () => {
122+
const request1 = XRequest(options);
123+
const request2 = XRequest(options);
124+
expect(request1).toBe(request2);
125+
const request3 = XRequest({ fetch: mockedXFetch, baseURL: options.baseURL });
126+
const request4 = XRequest({ fetch: mockedXFetch, baseURL: options.baseURL });
127+
expect(request3).toBe(request4);
86128
});
87129

88130
test('should handle error response', async () => {
89-
const onSuccess = jest.fn();
90-
const onError = jest.fn();
91-
const onUpdate = jest.fn();
92-
const params = { messages: [{ role: 'user', content: 'Hello' }] };
93-
94-
const mockedXFetch = xFetch as jest.Mock;
95131
mockedXFetch.mockRejectedValueOnce(new Error('Fetch failed'));
96-
97-
const request = XRequest({ baseURL, model });
98-
await request.create(params, { onSuccess, onError, onUpdate }).catch(() => {});
99-
100-
expect(onSuccess).not.toHaveBeenCalled();
101-
expect(onError).toHaveBeenCalledWith(new Error('Fetch failed'));
132+
await request.create(params, callbacks).catch(() => {});
133+
expect(callbacks.onSuccess).not.toHaveBeenCalled();
134+
expect(callbacks.onError).toHaveBeenCalledWith(new Error('Fetch failed'));
102135
});
103136

104137
test('should throw error for unsupported content type', async () => {
105-
const onSuccess = jest.fn();
106-
const onError = jest.fn();
107-
const onUpdate = jest.fn();
108-
const params = { messages: [{ role: 'user', content: 'Hello' }] };
109-
110-
const mockedXFetch = xFetch as jest.Mock;
138+
const contentType = 'text/plain';
111139
mockedXFetch.mockResolvedValueOnce({
112140
headers: {
113-
get: jest.fn().mockReturnValue('text/plain'),
141+
get: jest.fn().mockReturnValue(contentType),
114142
},
115143
});
144+
await request.create(params, callbacks).catch(() => {});
145+
expect(callbacks.onSuccess).not.toHaveBeenCalled();
146+
expect(callbacks.onError).toHaveBeenCalledWith(
147+
new Error(`The response content-type: ${contentType} is not support!`),
148+
);
149+
});
116150

117-
const request = XRequest({ baseURL, model });
118-
await request.create(params, { onSuccess, onError, onUpdate }).catch(() => {});
151+
test('should handle TransformStream errors', async () => {
152+
const errorTransform = new TransformStream({
153+
transform() {
154+
throw new Error('Transform error');
155+
},
156+
});
119157

120-
expect(onSuccess).not.toHaveBeenCalled();
121-
expect(onError).toHaveBeenCalledWith(
122-
new Error('The response content-type: text/plain is not support!'),
123-
);
158+
mockedXFetch.mockResolvedValueOnce({
159+
headers: {
160+
get: jest.fn().mockReturnValue('application/x-ndjson'),
161+
},
162+
body: mockNdJsonReadableStream(),
163+
});
164+
165+
await request.create(params, callbacks, errorTransform).catch(() => {});
166+
expect(callbacks.onError).toHaveBeenCalledWith(new Error('Transform error'));
167+
expect(callbacks.onSuccess).not.toHaveBeenCalled();
168+
expect(callbacks.onUpdate).not.toHaveBeenCalled();
124169
});
125170
});

components/x-request/__tests__/x-fetch.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ describe('xFetch', () => {
3535
expect(await response.text()).toBe('{"data": "modified"}');
3636
});
3737

38+
it('should throw an error while options.onResponse not return a Response instance', async () => {
39+
await expect(
40+
xFetch(baseURL, { middlewares: { onResponse: () => new Date() as any } }),
41+
).rejects.toThrow('The options.onResponse must return a Response instance!');
42+
});
43+
3844
it('should throw an error on non-200 status', async () => {
3945
(global.fetch as jest.Mock).mockResolvedValue(new Response(null, { status: 404 }));
4046

0 commit comments

Comments
 (0)