Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: XRequest to support custom protocols #293

Merged
merged 17 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
104 changes: 87 additions & 17 deletions components/x-request/__tests__/__snapshots__/demo.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
<div
class="ant-splitter ant-splitter-horizontal"
class="ant-space ant-space-horizontal ant-space-align-start"
style="column-gap:16px;row-gap:16px"
>
<div
class="ant-splitter-panel"
style="flex-basis:auto;flex-grow:1"
class="ant-space-item"
>
<button
class="ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"
Expand All @@ -18,23 +18,10 @@ exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
</button>
</div>
<div
aria-valuemax="0"
aria-valuemin="0"
aria-valuenow="50"
class="ant-splitter-bar"
role="separator"
>
<div
class="ant-splitter-bar-dragger"
/>
</div>
<div
class="ant-splitter-panel"
style="flex-basis:auto;flex-grow:1"
class="ant-space-item"
>
<div
class="ant-thought-chain ant-thought-chain-middle"
style="margin-left:16px"
>
<div
class="ant-thought-chain-item"
Expand Down Expand Up @@ -148,3 +135,86 @@ exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
</div>
</div>
`;

exports[`renders components/x-request/demo/custom-transformer.tsx correctly 1`] = `
<div
class="ant-space ant-space-horizontal ant-space-align-start"
style="column-gap:16px;row-gap:16px"
>
<div
class="ant-space-item"
>
<button
class="ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"
type="button"
>
<span>
Request - https://api.example.host/chat
</span>
</button>
</div>
<div
class="ant-space-item"
>
<div
class="ant-thought-chain ant-thought-chain-middle"
>
<div
class="ant-thought-chain-item"
>
<div
class="ant-thought-chain-item-header"
>
<span
class="ant-avatar ant-avatar-circle ant-avatar-icon ant-thought-chain-item-icon"
>
<span
aria-label="tags"
class="anticon anticon-tags"
role="img"
>
<svg
aria-hidden="true"
data-icon="tags"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
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"
/>
</svg>
</span>
</span>
<div
class="ant-thought-chain-item-header-box"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-ellipsis-single-line ant-thought-chain-item-title"
>
<strong>
Mock Custom Protocol - Log
</strong>
</span>
</div>
</div>
<div
class="ant-thought-chain-item-content"
>
<div
class="ant-thought-chain-item-content-box"
>
<pre
style="overflow:scroll"
>
<code />
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
`;
6 changes: 6 additions & 0 deletions components/x-request/__tests__/x-fetch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ describe('xFetch', () => {
expect(await response.text()).toBe('{"data": "modified"}');
});

it('should throw an error while options.onResponse not return a Response instance', async () => {
await expect(
xFetch(baseURL, { middlewares: { onResponse: () => new Date() as any } }),
).rejects.toThrow('The options.onResponse must return a Response instance!');
});

it('should throw an error on non-200 status', async () => {
(global.fetch as jest.Mock).mockResolvedValue(new Response(null, { status: 404 }));

Expand Down
150 changes: 150 additions & 0 deletions components/x-request/__tests__/x-request.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import XRequest from '../index';
import xFetch from '../x-fetch';

import type { SSEOutput } from '../../x-stream';
import type { XRequestCallbacks, XRequestOptions } from '../index';

jest.mock('../x-fetch', () => jest.fn());

const SSE_SEPARATOR = '\n\n';

const ND_JSON_SEPARATOR = '\n';

const sseEvent: SSEOutput = { event: 'message', data: '{"id":"0","content":"He"}' };

const sseData = `${Object.keys(sseEvent)
.map((key) => `${key}:${sseEvent[key as keyof SSEOutput]}`)
.join(ND_JSON_SEPARATOR)}${SSE_SEPARATOR}`;

const ndJsonData = `${JSON.stringify(sseEvent)}${ND_JSON_SEPARATOR}${JSON.stringify({ ...sseEvent, event: 'delta' })}`;

const options: XRequestOptions = {
baseURL: 'https://api.example.com/v1/chat',
model: 'gpt-3.5-turbo',
dangerouslyApiKey: 'dangerouslyApiKey',
Fixed Show fixed Hide fixed
};
YumoImer marked this conversation as resolved.
Show resolved Hide resolved

const params = { messages: [{ role: 'user', content: 'Hello' }] };

function mockSSEReadableStream() {
return new ReadableStream({
async start(controller) {
for (const chunk of sseData.split(SSE_SEPARATOR)) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
}

function mockNdJsonReadableStream() {
return new ReadableStream({
async start(controller) {
for (const chunk of ndJsonData.split(ND_JSON_SEPARATOR)) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
}

describe('XRequest Class', () => {
const callbacks: XRequestCallbacks<any> = {
onSuccess: jest.fn(),
onError: jest.fn(),
onUpdate: jest.fn(),
};

const mockedXFetch = xFetch as jest.Mock;

let request: ReturnType<typeof XRequest>;

beforeEach(() => {
jest.clearAllMocks();
request = XRequest(options);
});

test('should initialize with valid options', () => {
expect(request.baseURL).toBe(options.baseURL);
expect(request.model).toBe(options.model);
});

test('should throw error on invalid baseURL', () => {
expect(() => XRequest({ baseURL: '' })).toThrow('The baseURL is not valid!');
});

test('should create request and handle successful JSON response', async () => {
mockedXFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: jest.fn().mockReturnValue('application/json; charset=utf-8'),
},
json: jest.fn().mockResolvedValueOnce(params),
});
await request.create(params, callbacks);
expect(callbacks.onSuccess).toHaveBeenCalledWith([params]);
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onUpdate).toHaveBeenCalledWith(params);
});

test('should create request and handle streaming response', async () => {
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue('text/event-stream'),
},
body: mockSSEReadableStream(),
});
await request.create(params, callbacks);
expect(callbacks.onSuccess).toHaveBeenCalledWith([sseEvent]);
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onUpdate).toHaveBeenCalledWith(sseEvent);
});

test('should create request and handle custom response, e.g. application/x-ndjson', async () => {
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue('application/x-ndjson'),
},
body: mockNdJsonReadableStream(),
});
await request.create(params, callbacks, new TransformStream());
expect(callbacks.onSuccess).toHaveBeenCalledWith([
ndJsonData.split(ND_JSON_SEPARATOR)[0],
ndJsonData.split(ND_JSON_SEPARATOR)[1],
]);
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onUpdate).toHaveBeenCalledWith(ndJsonData.split(ND_JSON_SEPARATOR)[0]);
expect(callbacks.onUpdate).toHaveBeenCalledWith(ndJsonData.split(ND_JSON_SEPARATOR)[1]);
});

test('should reuse the same instance for the same baseURL or fetch', () => {
const request1 = XRequest(options);
const request2 = XRequest(options);
expect(request1).toBe(request2);
const request3 = XRequest({ fetch: mockedXFetch, baseURL: options.baseURL });
const request4 = XRequest({ fetch: mockedXFetch, baseURL: options.baseURL });
expect(request3).toBe(request4);
});

test('should handle error response', async () => {
mockedXFetch.mockRejectedValueOnce(new Error('Fetch failed'));
await request.create(params, callbacks).catch(() => {});
expect(callbacks.onSuccess).not.toHaveBeenCalled();
expect(callbacks.onError).toHaveBeenCalledWith(new Error('Fetch failed'));
});

test('should throw error for unsupported content type', async () => {
const contentType = 'text/plain';
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue(contentType),
},
});
await request.create(params, callbacks).catch(() => {});
expect(callbacks.onSuccess).not.toHaveBeenCalled();
expect(callbacks.onError).toHaveBeenCalledWith(
new Error(`The response content-type: ${contentType} is not support!`),
);
});
});
59 changes: 27 additions & 32 deletions components/x-request/demo/basic.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LoadingOutlined, TagsOutlined } from '@ant-design/icons';
import { ThoughtChain, XRequest } from '@ant-design/x';
import { Button, Descriptions, Splitter } from 'antd';
import { Button, Descriptions, Space } from 'antd';
import React from 'react';

import type { ThoughtChainItem } from '@ant-design/x';
Expand All @@ -23,7 +23,7 @@ const exampleRequest = XRequest({

const App = () => {
const [status, setStatus] = React.useState<ThoughtChainItem['status']>();
const [lines, setLines] = React.useState<any[]>([]);
const [lines, setLines] = React.useState<Record<string, string>[]>([]);

async function request() {
setStatus('pending');
Expand Down Expand Up @@ -51,36 +51,31 @@ const App = () => {
}

return (
<Splitter>
<Splitter.Panel>
<Button type="primary" disabled={status === 'pending'} onClick={request}>
Request - {BASE_URL}
{PATH}
</Button>
</Splitter.Panel>
<Splitter.Panel>
<ThoughtChain
style={{ marginLeft: 16 }}
items={[
{
title: 'Request Log',
status: status,
icon: status === 'pending' ? <LoadingOutlined /> : <TagsOutlined />,
description:
status === 'error' &&
exampleRequest.baseURL === BASE_URL + PATH &&
'Please replace the BASE_URL, PATH, MODEL, API_KEY with your own values.',
content: (
<Descriptions column={1}>
<Descriptions.Item label="Status">{status || '-'}</Descriptions.Item>
<Descriptions.Item label="Update Times">{lines.length}</Descriptions.Item>
</Descriptions>
),
},
]}
/>
</Splitter.Panel>
</Splitter>
<Space align="start" size={16}>
<Button type="primary" disabled={status === 'pending'} onClick={request}>
Request - {BASE_URL}
{PATH}
</Button>
<ThoughtChain
items={[
{
title: 'Request Log',
status: status,
icon: status === 'pending' ? <LoadingOutlined /> : <TagsOutlined />,
description:
status === 'error' &&
exampleRequest.baseURL === BASE_URL + PATH &&
'Please replace the BASE_URL, PATH, MODEL, API_KEY with your own values.',
content: (
<Descriptions column={1}>
<Descriptions.Item label="Status">{status || '-'}</Descriptions.Item>
<Descriptions.Item label="Update Times">{lines.length}</Descriptions.Item>
</Descriptions>
),
},
]}
/>
</Space>
);
};

Expand Down
7 changes: 7 additions & 0 deletions components/x-request/demo/custom-transformer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## zh-CN

为 `XRequest` 配置自定义的 `transformStream` , 示例中使用 `application/x-ndjson` 数据演示

## en-US

Configure a custom `transformStream` for `XRequest`. The following example demonstrates how to handle `application/x-ndjson` data format.
Loading
Loading