Skip to content

Commit d755dad

Browse files
xqvvuc121914yu
authored andcommitted
feat: integrate ts-rest (#5741)
* feat: integrate ts-rest * chore: classify core contract and pro contract * chore: update lockfile * chore: tweak dir structure * chore: tweak dir structure
1 parent 2be5353 commit d755dad

File tree

38 files changed

+2630
-180
lines changed

38 files changed

+2630
-180
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { contract } from './contracts';
2+
import { initClient, tsRestFetchApi } from '@ts-rest/core';
3+
import { TOKEN_ERROR_CODE } from '../../error/errorCode';
4+
import { getNanoid } from '../../string/tools';
5+
import { type ApiFetcherArgs } from '@ts-rest/core';
6+
import { AnyResponseSchema } from '../../type';
7+
import { ZodError } from 'zod';
8+
import { getWebReqUrl } from '../../../../web/common/system/utils';
9+
10+
export const client = initClient(contract, {
11+
baseUrl: getWebReqUrl('/api'),
12+
throwOnUnknownStatus: true,
13+
validateResponse: false,
14+
credentials: 'include',
15+
baseHeaders: {
16+
'Content-Type': 'application/json;charset=utf-8'
17+
},
18+
api: async (args: BeforeFetchOptions) => {
19+
const prepare = beforeFetch(args);
20+
const response = await tsRestFetchApi(args);
21+
return afterFetch(response, prepare);
22+
}
23+
});
24+
25+
const WHITE_LIST = ['/chat/share', '/chat', '/login'];
26+
async function isTokenExpired() {
27+
if (WHITE_LIST.includes(window.location.pathname)) return;
28+
29+
await client.support.user.account.logout();
30+
const lastRoute = encodeURIComponent(location.pathname + location.search);
31+
window.location.replace(getWebReqUrl(`/login?lastRoute=${lastRoute}`));
32+
}
33+
34+
export function checkBusinessCode(code: number) {
35+
if (code in TOKEN_ERROR_CODE) {
36+
isTokenExpired();
37+
return;
38+
}
39+
}
40+
41+
type Item = { id: string; controller: AbortController };
42+
const queue = new Map<string, Item[]>();
43+
function checkMaxRequestLimitation(options: { url: string; max: number }): {
44+
id: string;
45+
signal: AbortSignal;
46+
release: () => void;
47+
} {
48+
const { url, max } = options;
49+
const id = getNanoid();
50+
const controller = new AbortController();
51+
const item = queue.get(url);
52+
53+
const current = item ?? [];
54+
if (current.length >= max) {
55+
const first = current.shift()!;
56+
first.controller.abort();
57+
}
58+
current.push({ id, controller });
59+
if (!item) queue.set(url, current);
60+
61+
const release = () => {
62+
const item = queue.get(url);
63+
if (!item) return;
64+
65+
const index = item.findIndex((item) => item.id === id);
66+
if (index !== -1) {
67+
item.splice(index, 1);
68+
}
69+
70+
if (item.length <= 0) {
71+
queue.delete(url);
72+
}
73+
};
74+
75+
return { id, signal: controller.signal, release };
76+
}
77+
78+
function checkHttpStatus(status: number): status is 200 {
79+
if (status !== 200) return false;
80+
return true;
81+
}
82+
83+
type BeforeFetchOptions = ApiFetcherArgs & { max?: number };
84+
function beforeFetch(options: BeforeFetchOptions):
85+
| {
86+
limit: { id: string; url: string; release: () => void };
87+
}
88+
| undefined {
89+
const { max, ...args } = options;
90+
if (!max || max <= 0) return;
91+
92+
const { id, signal, release } = checkMaxRequestLimitation({ url: args.path, max });
93+
args.fetchOptions ??= {};
94+
args.fetchOptions.signal = signal;
95+
96+
return {
97+
limit: { id, url: args.path, release }
98+
};
99+
}
100+
101+
function afterFetch(
102+
response: Awaited<ReturnType<typeof tsRestFetchApi>>,
103+
prepare?: ReturnType<typeof beforeFetch>
104+
) {
105+
if (checkHttpStatus(response.status)) {
106+
try {
107+
const body = AnyResponseSchema.parse(response.body);
108+
109+
response.body = body.data;
110+
111+
if (prepare?.limit) {
112+
prepare.limit.release();
113+
}
114+
115+
return response;
116+
} catch (error) {
117+
if (error instanceof ZodError) {
118+
throw new Error(error.message);
119+
}
120+
121+
throw new Error('Unknown error while intercept response');
122+
}
123+
} else {
124+
throw new Error(`HTTP error, status: ${response.status}`);
125+
}
126+
}
127+
128+
type Client = typeof client;
129+
type U<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : U<T[K]> }[keyof T];
130+
export type Endpoints = U<Client>;
131+
type _Options<T extends Endpoints> = NonNullable<Parameters<T>[0]>;
132+
type ExtractBodySchema<T extends Endpoints> = 'body' extends keyof _Options<T>
133+
? _Options<T>['body']
134+
: never;
135+
type ExtractQuerySchema<T extends Endpoints> = 'query' extends keyof _Options<T>
136+
? _Options<T>['query']
137+
: never;
138+
export type Params<T extends Endpoints> = (ExtractBodySchema<T> extends never
139+
? {}
140+
: ExtractBodySchema<T>) &
141+
(ExtractQuerySchema<T> extends never ? {} : ExtractQuerySchema<T>);
142+
export type Options<T extends Endpoints> = Omit<_Options<T>, 'body' | 'query'>;
143+
type Body<T extends Endpoints> = Extract<Awaited<ReturnType<T>>, { status: 200 }>['body'];
144+
type RestAPIResult<T extends Endpoints> = Body<T>;
145+
146+
const call = async <T extends Endpoints>(
147+
api: T,
148+
options: _Options<T>
149+
): Promise<RestAPIResult<T>> => {
150+
const res = await api(options as any);
151+
152+
if (res.status !== 200) {
153+
throw new Error(`Unexpected status: ${res.status}`);
154+
}
155+
156+
return res.body as RestAPIResult<T>;
157+
};
158+
159+
export const RestAPI = <T extends Endpoints>(
160+
endpoint: T,
161+
transform?: (params: Params<T>) => {
162+
body?: ExtractBodySchema<T> extends never ? any : ExtractBodySchema<T>;
163+
query?: ExtractQuerySchema<T> extends never ? any : ExtractQuerySchema<T>;
164+
}
165+
) => {
166+
return (params?: Params<T>, options?: Options<T>) => {
167+
const transformedData = params && transform ? transform(params) : {};
168+
const finalOptions = { ...options, ...transformedData } as _Options<T>;
169+
170+
return call(endpoint, finalOptions);
171+
};
172+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { settingContract } from './setting';
2+
import { c } from '../../../init';
3+
4+
export const chatContract = c.router({
5+
setting: settingContract
6+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { ObjectIdSchema } from '../../../../../../type';
2+
import {
3+
ChatFavouriteAppResponseItemSchema,
4+
ChatFavouriteAppUpdateSchema
5+
} from '../../../../../../../core/chat/favouriteApp/type';
6+
import { c } from '../../../../../init';
7+
import { z } from 'zod';
8+
9+
export const favouriteContract = c.router({
10+
list: {
11+
path: '/proApi/core/chat/setting/favourite/list',
12+
method: 'GET',
13+
query: z.object({
14+
name: z.string().optional().openapi({ example: 'FastGPT' }),
15+
tag: z.string().optional().openapi({ example: 'i7Ege2W2' })
16+
}),
17+
responses: {
18+
200: z.array(ChatFavouriteAppResponseItemSchema)
19+
},
20+
metadata: {
21+
tags: ['chat']
22+
},
23+
description: '获取精选应用列表',
24+
summary: '获取精选应用列表'
25+
},
26+
27+
update: {
28+
path: '/proApi/core/chat/setting/favourite/update',
29+
method: 'PUT',
30+
body: ChatFavouriteAppUpdateSchema,
31+
responses: {
32+
200: c.type<void>()
33+
},
34+
metadata: {
35+
tags: ['chat']
36+
},
37+
description: '更新精选应用',
38+
summary: '更新精选应用'
39+
},
40+
41+
delete: {
42+
path: '/proApi/core/chat/setting/favourite/delete',
43+
method: 'DELETE',
44+
query: z.object({
45+
id: ObjectIdSchema
46+
}),
47+
responses: {
48+
200: c.type<void>()
49+
},
50+
metadata: {
51+
tags: ['chat']
52+
},
53+
description: '删除精选应用',
54+
summary: '删除精选应用'
55+
},
56+
57+
order: {
58+
path: '/proApi/core/chat/setting/favourite/order',
59+
method: 'PUT',
60+
body: z.array(
61+
z.object({
62+
id: ObjectIdSchema,
63+
order: z.number()
64+
})
65+
),
66+
responses: {
67+
200: c.type<void>()
68+
},
69+
metadata: {
70+
tags: ['chat']
71+
},
72+
description: '更新精选应用顺序',
73+
summary: '更新精选应用顺序'
74+
},
75+
76+
tags: {
77+
path: '/proApi/core/chat/setting/favourite/tags',
78+
method: 'PUT',
79+
body: z.array(
80+
z.object({
81+
id: z.string(),
82+
tags: z.array(z.string())
83+
})
84+
),
85+
responses: {
86+
200: c.type<void>()
87+
},
88+
metadata: {
89+
tags: ['chat']
90+
},
91+
description: '更新精选应用标签',
92+
summary: '更新精选应用标签'
93+
}
94+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
ChatSettingResponseSchema,
3+
ChatSettingSchema
4+
} from '../../../../../../core/chat/setting/type';
5+
import { c } from '../../../../init';
6+
import { favouriteContract } from './favourite';
7+
8+
export const settingContract = c.router({
9+
favourite: favouriteContract,
10+
11+
detail: {
12+
path: '/proApi/core/chat/setting/detail',
13+
method: 'GET',
14+
responses: {
15+
200: ChatSettingResponseSchema
16+
},
17+
metadata: {
18+
tags: ['chat']
19+
},
20+
description: '获取聊天设置',
21+
summary: '获取聊天设置'
22+
},
23+
24+
update: {
25+
path: '/proApi/core/chat/setting/update',
26+
method: 'PUT',
27+
body: ChatSettingSchema.partial(),
28+
responses: {
29+
200: c.type<void>()
30+
},
31+
metadata: {
32+
tags: ['chat']
33+
},
34+
description: '更新聊天设置',
35+
summary: '更新聊天设置'
36+
}
37+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { supportContract } from './support';
2+
import { chatContract } from './chat';
3+
import { c } from '../../init';
4+
5+
// 前端使用的完整合约(开源 + Pro)
6+
// FastGPT 后端使用的合约
7+
export const contract = c.router({
8+
chat: {
9+
...chatContract
10+
},
11+
support: {
12+
...supportContract
13+
}
14+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { userContract } from './user';
2+
import { c } from '../../../init';
3+
4+
export const supportContract = c.router({
5+
user: userContract
6+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { c } from '../../../../../init';
2+
3+
export const accountContract = c.router({
4+
logout: {
5+
path: '/support/user/account/login',
6+
method: 'POST',
7+
body: c.type<undefined>(),
8+
responses: {
9+
200: c.type<void>()
10+
},
11+
metadata: {
12+
tags: ['support']
13+
},
14+
description: '退出登录',
15+
summary: '退出登录'
16+
}
17+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { accountContract } from '../../../../fastgpt/contracts/support/user/account';
2+
import { c } from '../../../../init';
3+
4+
export const userContract = c.router({
5+
account: accountContract
6+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createNextRouter } from '@ts-rest/next';
2+
import { createNextRoute } from '@ts-rest/next';
3+
import { contract } from './contracts';
4+
5+
/**
6+
* 创建 FastGPT 单个路由
7+
*/
8+
export function createServerRoute(
9+
implementation: Parameters<typeof createNextRoute<typeof contract>>[1]
10+
) {
11+
return createNextRoute(contract, implementation);
12+
}
13+
14+
/**
15+
* 创建 FastGPT 路由器
16+
*/
17+
export function createServerRouter(
18+
router: Parameters<typeof createNextRouter<typeof contract>>[1]
19+
) {
20+
return createNextRouter(contract, router);
21+
}

0 commit comments

Comments
 (0)