feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IdlConfig } from './utils';
class ConfigCenter {
private config: Map<string, IdlConfig> = new Map();
register(service: string, config: IdlConfig): void {
this.config.set(service, config);
}
getConfig(service: string): IdlConfig | undefined {
return this.config.get(service);
}
}
export const configCenter = new ConfigCenter();
export function registerConfig(service: string, config: IdlConfig): void {
if (configCenter.getConfig(service)) {
console.warn(`${service} api config has already been set,make sure they are the same`);
}
configCenter.register(service, config);
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type IOptions, normalizeRequest } from './utils';
import type { IMeta, CustomAPIMeta } from './types';
export interface ApiLike<T, K, O = unknown, B extends boolean = false> {
(req: T, option?: O extends object ? IOptions & O : IOptions): Promise<K>;
meta: IMeta;
/** fork 一份实例,该实例具有可中止请求的能力 */
withAbort: () => CancelAbleApi<T, K, O, B>;
}
export interface CancelAbleApi<T, K, O = unknown, B extends boolean = false>
extends ApiLike<T, K, O, B> {
// 中止请求
abort: () => void;
// 是否是取消
isAborted: () => boolean;
}
/**
* 自定义构建 api 方法
* @param meta
* @param cancelable
* @param useCustom
* @returns
*/
// eslint-disable-next-line max-params
export function createAPI<T extends {}, K, O = unknown, B extends boolean = false>(
meta: IMeta,
cancelable?: B,
useCustom = false,
customOption?: O extends object ? IOptions & O : IOptions,
): B extends false ? ApiLike<T, K, O, B> : CancelAbleApi<T, K, O, B> {
let abortController: AbortController | undefined;
let pending: undefined | boolean;
async function api(
req: T,
option: O extends object ? IOptions & O : IOptions,
): Promise<K> {
pending = true;
option = { ...(option || {}), ...customOption };
// 这里可以使用传进来的 req 作为默认映射,减少需要在 customAPI 中,需要手动绑定的情况
if (useCustom) {
const mappingKeys: string[] = Object.keys(meta.reqMapping)
.map(key => meta.reqMapping[key])
.reduce((a, b) => [...a, ...b], []);
const defaultFiled = Object.keys(req).filter(
field => !mappingKeys.includes(field),
);
if (['POST', 'PUT', 'PATCH'].includes(meta.method)) {
meta.reqMapping.body = [
...defaultFiled,
...(meta.reqMapping.body || []),
];
}
if (['GET', 'DELETE'].includes(meta.method)) {
meta.reqMapping.query = [
...defaultFiled,
...(meta.reqMapping.query || []),
];
}
}
const { client, uri, requestOption } = normalizeRequest(req, meta, option);
if (!abortController && cancelable) {
abortController = new AbortController();
}
if (abortController) {
requestOption.signal = abortController.signal;
}
try {
const res = await client(uri, requestOption, option);
return res;
} finally {
pending = false;
}
}
function abort() {
/**
* 这里加上 pending 状态的原因是abortController.signal 的状态值只受控于 abortController.abort() 方法;
* 不管请求是否完成或者异常,只要调用 abortController.abort(), abortController.signal.aborted 必定为 true
* 这样不好判断请求是否真 aborted
*
* 这里改为,只有在请求 pending 的情况下,可执行 abort()
* isAborted === true 时,请求异常必定是因为手动 abort 导致的
*/
if (pending === true && cancelable && abortController) {
abortController.abort();
}
}
function isAborted() {
return !!abortController?.signal.aborted;
}
function withAbort() {
return createAPI<T, K, O, true>(meta, true, useCustom, customOption);
}
api.meta = meta;
api.withAbort = withAbort;
if (cancelable) {
api.abort = abort;
api.isAborted = isAborted;
}
return api as any;
}
/**
* 一些非泛化的接口,可以使用改方法构建,方便统一管理接口
* @param customAPIMeta
* @param cancelable
* @returns
* @example
*
*/
export function createCustomAPI<
T extends {},
K,
O = unknown,
B extends boolean = false,
>(customAPIMeta: CustomAPIMeta, cancelable?: B) {
const name = `${customAPIMeta.method}_${customAPIMeta.url}`;
const meta: IMeta = {
...customAPIMeta,
reqMapping: customAPIMeta.reqMapping || {},
name,
service: 'CustomAPI',
schemaRoot: '',
reqType: `${name}_req`,
resType: `${name}_res`,
};
return createAPI<T, K, O, B>(meta, cancelable, true);
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './create-api';
export * from './config-center';
export { type IMeta } from './types';

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface IMeta {
reqType: string;
resType: string;
url: string;
method: string;
reqMapping: IHttpRpcMapping;
resMapping?: IHttpRpcMapping; // res mapping
name: string;
service: string;
schemaRoot: string;
serializer?: string;
}
type Fields = string[];
export interface IHttpRpcMapping {
path?: Fields; // path参数
query?: Fields; // query参数
body?: Fields; // body 参数
header?: Fields; // header 参数
status_code?: Fields; // http状态码
cookie?: Fields; // cookie
entire_body?: Fields;
raw_body?: Fields;
}
export interface CustomAPIMeta {
url: string;
method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH';
reqMapping?: IHttpRpcMapping;
}

View File

@@ -0,0 +1,218 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import qs from 'qs';
import type { IMeta } from './types';
import { configCenter } from './config-center';
export interface ServiceConfig {
[key: string]: {
methods?: {
[key: string]: Omit<IdlConfig, 'clientFactory'>;
};
} & Omit<IdlConfig, 'clientFactory'>;
}
export interface IdlConfig {
// client 工厂方法,要求返回一个 fetchClient 函数,使用 meta 总的信息,可实现灵活的 client 配置
clientFactory?: (
meta: IMeta,
) => (uri: string, init: RequestInit, opt: any) => any;
// uri 前缀,如果 client 中设置了,这里可以不设置
uriPrefix?: string;
getParams?: (key: string) => string;
// 服务级别的配置
services?: ServiceConfig;
// 开发时,如果本地校验失败,这里可回调,通常是弹 toast
onVerifyReqError?: (message: string, ctx: any) => void;
}
export interface IOptions {
config?: IdlConfig;
// 透传 request options 的选项
requestOptions?: Record<string, any>;
[key: string]: any;
}
export interface PathPrams<T> {
pathParams?: T;
}
export function getConfig(service: string, method: string): IdlConfig {
// 手动注册的配置优先级比全局变量高
let config: IdlConfig | undefined = configCenter.getConfig(service);
if (!config) {
config = {};
if (config.services && config.services[service]) {
const serviceConfig = config.services[service];
const { methods, ...rest } = serviceConfig;
Object.assign(config, rest);
if (methods && methods[method]) {
Object.assign(config, methods[method]);
}
}
delete config.services;
}
return config;
}
function getValue(origin: any, fields: string[]) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const res = {} as Record<string, any>;
fields.forEach(i => {
res[i] = origin[i];
});
return res;
}
// eslint-disable-next-line max-params
export function unifyUrl(
uri: string,
pathParams: string[],
option: IdlConfig & PathPrams<any>,
req: Record<string, any>,
): { apiUri: string; unmappedParams: string[] } {
let apiUri = uri;
pathParams = pathParams || [];
const unmappedParams = [] as string[];
const matches = apiUri.match(/:([^/]+)/g) || [];
if (matches.length === 0) {
return { apiUri, unmappedParams };
}
matches.forEach(item => {
const target = item.slice(1);
if (!pathParams.includes(target)) {
const param =
option.pathParams?.[target] ||
(option.getParams && option.getParams(target));
apiUri = apiUri.replace(item, param || '');
unmappedParams.push(target);
} else {
const param =
req[target] ||
option.pathParams?.[target] ||
option.pathParams?.[target] ||
(option.getParams && option.getParams(target));
apiUri = apiUri.replace(item, param);
}
});
return { apiUri, unmappedParams };
}
const ContentTypeMap = {
json: 'application/json',
urlencoded: 'application/x-www-form-urlencoded',
form: 'multipart/form-data',
};
// eslint-disable-next-line complexity
export function normalizeRequest(
req: Record<string, any>,
meta: IMeta,
option?: IOptions & PathPrams<any>,
) {
const config = {
...getConfig(meta.service, meta.method),
...(option?.config ?? {}),
};
const { apiUri } = unifyUrl(
meta.url,
meta.reqMapping.path || [],
{ ...config, pathParams: option?.pathParams ?? {} },
req,
);
const { uriPrefix = '', clientFactory } = config;
if (!clientFactory) {
// todo 这里考虑给个默认的 client防止某些公共 package 在一些异常情况下使用
throw new Error('Lack of clientFactory config');
}
let uri = uriPrefix + apiUri;
let headers: Record<string, string> = {};
headers['Content-Type'] =
meta.serializer && ContentTypeMap[meta.serializer]
? ContentTypeMap[meta.serializer]
: 'application/json';
if (option?.requestOptions?.headers) {
headers = { ...headers, ...option.requestOptions.headers };
// 合并了 header可删除
delete option.requestOptions.headers;
}
if (meta.reqMapping.query && meta.reqMapping.query.length > 0) {
// 这里默认 skipNulls网关后端需要忽略 null
uri = `${uri}?${qs.stringify(getValue(req, meta.reqMapping.query), {
skipNulls: true,
arrayFormat: 'comma',
})}`;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const requestOption = {
method: meta.method,
headers,
credentials: 'same-origin',
} as RequestInit;
if (meta.reqMapping.entire_body && meta.reqMapping.entire_body.length > 0) {
if (meta.reqMapping.entire_body.length === 1) {
// 默认处理为 json ,如有其他场景需要支持,后需要再支持
requestOption.body = req[meta.reqMapping.entire_body[0]];
} else {
throw new Error('idl invalid entire_body should be only one filed');
}
} else if (meta.reqMapping.body && meta.reqMapping.body.length > 0) {
const body = getValue(req, meta.reqMapping.body);
requestOption.body = body as BodyInit;
if (meta.serializer === 'form') {
const formData = new FormData();
Object.keys(body).forEach(key => {
const formItemValue =
body[key] instanceof File
? new Blob([body[key]], { type: body[key].type })
: body[key];
formData.append(key, formItemValue);
});
requestOption.body = formData;
}
if (meta.serializer === 'urlencoded') {
requestOption.body = qs.stringify(body, {
skipNulls: true,
arrayFormat: 'comma',
});
}
}
if (meta.reqMapping.header && meta.reqMapping.header.length > 0) {
requestOption.headers = {
...headers,
...getValue(req, meta.reqMapping.header),
};
}
// 旧版的 ferry 中,即使 idl 没有声明body也需要加一个 空的 body
if (
!requestOption.body &&
['POST', 'PUT', 'PATCH'].includes(
(requestOption.method || '').toUpperCase(),
)
) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
requestOption.body = {} as BodyInit;
}
return { uri, requestOption, client: clientFactory(meta) };
}