refactor: migrate from Rollup to Bun for build configuration
feat: update adapter to use globalThis for origin resolution fix: remove unused ClientQuery export from query.ts chore: update tsconfig to include test files and set rootDir feat: add create-query functionality for dynamic API generation feat: implement QueryApi with enhanced type inference from JSON Schema test: add comprehensive API tests for QueryApi functionality test: create demo routes and schemas for testing purposes docs: add type inference demo for QueryApi usage
This commit is contained in:
@@ -60,7 +60,7 @@ export const adapter = async (opts: AdapterOpts = {}, overloadOpts?: RequestInit
|
||||
if (opts?.url?.startsWith('http')) {
|
||||
url = new URL(opts.url);
|
||||
} else {
|
||||
origin = window?.location?.origin || 'http://localhost:51515';
|
||||
origin = globalThis?.location?.origin || 'http://localhost:51515';
|
||||
url = new URL(opts?.url || '', origin);
|
||||
}
|
||||
const isGet = method === 'GET';
|
||||
|
||||
130
src/create-query/index.ts
Normal file
130
src/create-query/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
|
||||
type RouteInfo = {
|
||||
path: string;
|
||||
key: string;
|
||||
id: string;
|
||||
description?: string;
|
||||
metadata?: {
|
||||
summary?: string;
|
||||
args?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
export const createQueryByRoutes = (list: RouteInfo[]) => {
|
||||
const obj: any = {};
|
||||
for (const route of list) {
|
||||
if (!obj[route.path]) {
|
||||
obj[route.path] = {};
|
||||
}
|
||||
obj[route.path][route.key] = route;
|
||||
}
|
||||
const code = `
|
||||
import { createQueryApi } from '@kevisual/query/api';
|
||||
const api = ${generateApiCode(obj)} as const;
|
||||
const queryApi = createQueryApi({ api });
|
||||
export { queryApi };
|
||||
`
|
||||
return code;
|
||||
}
|
||||
|
||||
// 生成带注释的对象字符串
|
||||
function generateApiCode(obj: any): string {
|
||||
let code = '{\n';
|
||||
const paths = Object.keys(obj);
|
||||
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const path = paths[i];
|
||||
const methods = obj[path];
|
||||
|
||||
code += ` "${path}": {\n`;
|
||||
|
||||
const keys = Object.keys(methods);
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
const key = keys[j];
|
||||
const route = methods[key];
|
||||
if (route?.id) {
|
||||
if (route.id.startsWith('rand-')) {
|
||||
delete route.id; // 删除随机生成的 ID
|
||||
}
|
||||
}
|
||||
const description = route?.metadata?.summary || route?.description || '';
|
||||
const args = route?.metadata?.args || {};
|
||||
|
||||
// 添加 JSDoc 注释
|
||||
if (description || Object.keys(args).length > 0) {
|
||||
code += ` /**\n`;
|
||||
|
||||
// 添加主描述
|
||||
if (description) {
|
||||
// 转义描述中的特殊字符
|
||||
const escapedDescription = description
|
||||
.replace(/\\/g, '\\\\') // 转义反斜杠
|
||||
.replace(/\*/g, '\\*') // 转义星号
|
||||
.replace(/\n/g, '\n * '); // 处理多行描述
|
||||
code += ` * ${escapedDescription}\n`;
|
||||
}
|
||||
|
||||
// 添加参数描述
|
||||
if (Object.keys(args).length > 0) {
|
||||
if (description) {
|
||||
code += ` *\n`; // 添加空行分隔
|
||||
}
|
||||
code += ` * @param data - Request parameters\n`;
|
||||
|
||||
for (const [argName, schema] of Object.entries(args)) {
|
||||
const argSchema = schema as any;
|
||||
const argType = argSchema.type || 'unknown';
|
||||
const argDesc = argSchema.description || '';
|
||||
|
||||
// 构建类型信息
|
||||
let typeInfo = argType;
|
||||
if (argType === 'string' && argSchema.enum) {
|
||||
typeInfo = argSchema.enum.map((v: any) => `"${v}"`).join(' | ');
|
||||
} else if (argType === 'number' || argType === 'integer') {
|
||||
const constraints = [];
|
||||
if (argSchema.minimum !== undefined) constraints.push(`min: ${argSchema.minimum}`);
|
||||
if (argSchema.maximum !== undefined) constraints.push(`max: ${argSchema.maximum}`);
|
||||
if (argSchema.exclusiveMinimum !== undefined) constraints.push(`> ${argSchema.exclusiveMinimum}`);
|
||||
if (argSchema.exclusiveMaximum !== undefined) constraints.push(`< ${argSchema.exclusiveMaximum}`);
|
||||
if (constraints.length > 0) typeInfo += ` (${constraints.join(', ')})`;
|
||||
} else if (argType === 'string') {
|
||||
const constraints = [];
|
||||
if (argSchema.minLength !== undefined) constraints.push(`minLength: ${argSchema.minLength}`);
|
||||
if (argSchema.maxLength !== undefined) constraints.push(`maxLength: ${argSchema.maxLength}`);
|
||||
if (argSchema.format) constraints.push(`format: ${argSchema.format}`);
|
||||
if (constraints.length > 0) typeInfo += ` (${constraints.join(', ')})`;
|
||||
}
|
||||
|
||||
// 转义参数描述
|
||||
const escapedArgDesc = argDesc
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\*/g, '\\*')
|
||||
.replace(/\n/g, ' ');
|
||||
|
||||
code += ` * @param data.${argName} - {${typeInfo}}${escapedArgDesc ? ' ' + escapedArgDesc : ''}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
code += ` */\n`;
|
||||
}
|
||||
|
||||
code += ` "${key}": ${JSON.stringify(route, null, 2).split('\n').map((line, idx) =>
|
||||
idx === 0 ? line : ' ' + line
|
||||
).join('\n')}`;
|
||||
|
||||
if (j < keys.length - 1) {
|
||||
code += ',';
|
||||
}
|
||||
code += '\n';
|
||||
}
|
||||
|
||||
code += ` }`;
|
||||
if (i < paths.length - 1) {
|
||||
code += ',';
|
||||
}
|
||||
code += '\n';
|
||||
}
|
||||
|
||||
code += '}';
|
||||
return code;
|
||||
}
|
||||
|
||||
136
src/query-api.ts
Normal file
136
src/query-api.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { DataOpts, Query } from "./query.ts";
|
||||
import { z } from "zod";
|
||||
import { createQueryByRoutes } from "./create-query/index.ts";
|
||||
import { pick } from 'es-toolkit'
|
||||
type Pos = {
|
||||
path?: string;
|
||||
key?: string;
|
||||
id?: string;
|
||||
metadata?: {
|
||||
args?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
// JSON Schema 类型推断 - 使用更精确的类型匹配
|
||||
type InferFromJSONSchema<T> =
|
||||
T extends { type: "string"; enum: readonly (infer E)[] } ? E :
|
||||
T extends { type: "string"; enum: (infer E)[] } ? E :
|
||||
T extends { type: "string" } ? string :
|
||||
T extends { type: "number" } ? number :
|
||||
T extends { type: "integer" } ? number :
|
||||
T extends { type: "boolean" } ? boolean :
|
||||
T extends { type: "object"; properties: infer P }
|
||||
? { [K in keyof P]: InferFromJSONSchema<P[K]> }
|
||||
: T extends { type: "array"; items: infer I }
|
||||
? Array<InferFromJSONSchema<I>>
|
||||
: unknown;
|
||||
|
||||
// 统一类型推断:支持 Zod schema 和原始 JSON Schema
|
||||
type InferType<T> =
|
||||
T extends z.ZodType<infer U> ? U : // Zod schema
|
||||
T extends { type: infer TType } ? InferFromJSONSchema<T> : // 任何包含 type 字段的 JSON Schema(忽略 $schema)
|
||||
T;
|
||||
|
||||
// 提取 args 对象,将每个 Zod schema 或 JSON Schema 转换为实际类型
|
||||
type ExtractArgsFromMetadata<T> = T extends { metadata?: { args?: infer A } }
|
||||
? A extends Record<string, any>
|
||||
? { [K in keyof A]: InferType<A[K]> }
|
||||
: never
|
||||
: never;
|
||||
|
||||
// 类型映射:将 API 配置转换为方法签名
|
||||
type ApiMethods<P extends { [path: string]: { [key: string]: Pos } }> = {
|
||||
[Path in keyof P]: {
|
||||
[Key in keyof P[Path]]: (
|
||||
data?: Partial<ExtractArgsFromMetadata<P[Path][Key]>>,
|
||||
opts?: DataOpts
|
||||
) => ReturnType<Query['post']>
|
||||
}
|
||||
}
|
||||
type QueryApiOpts<P extends { [path: string]: { [key: string]: Pos } } = {}> = {
|
||||
query?: Query,
|
||||
api?: P
|
||||
}
|
||||
export class QueryApi<P extends { [path: string]: { [key: string]: Pos } } = {}> {
|
||||
query: Query;
|
||||
|
||||
constructor(opts?: QueryApiOpts<P>) {
|
||||
this.query = opts?.query ?? new Query();
|
||||
if (opts?.api) {
|
||||
this.createApi(opts.api);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用泛型来推断类型
|
||||
post<T extends Pos>(
|
||||
pos: T,
|
||||
data?: Partial<ExtractArgsFromMetadata<T>>,
|
||||
opts?: DataOpts
|
||||
) {
|
||||
const _pos = pick(pos, ['path', 'key', 'id']);
|
||||
return this.query.post({
|
||||
..._pos,
|
||||
payload: data
|
||||
}, opts)
|
||||
}
|
||||
|
||||
createApi(api: P): asserts this is this & ApiMethods<P> {
|
||||
const that = this as any;
|
||||
const apiEntries = Object.entries(api);
|
||||
const keepPaths = ['createApi', 'query', 'post'];
|
||||
|
||||
for (const [path, methods] of apiEntries) {
|
||||
if (keepPaths.includes(path)) continue;
|
||||
|
||||
// 为每个 path 创建命名空间对象
|
||||
if (!that[path]) {
|
||||
that[path] = {};
|
||||
}
|
||||
|
||||
for (const [key, pos] of Object.entries(methods)) {
|
||||
that[path][key] = (data?: Partial<ExtractArgsFromMetadata<typeof pos>>, opts?: DataOpts) => {
|
||||
const _pos = pick(pos, ['path', 'key', 'id']);
|
||||
return that.query.post({
|
||||
..._pos,
|
||||
payload: data
|
||||
}, opts);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建工厂函数,提供更好的类型推断
|
||||
export function createQueryApi<P extends { [path: string]: { [key: string]: Pos } }>(
|
||||
opts?: QueryApiOpts<P>
|
||||
): QueryApi<P> & ApiMethods<P> {
|
||||
return new QueryApi(opts) as QueryApi<P> & ApiMethods<P>;
|
||||
}
|
||||
|
||||
export { createQueryByRoutes };
|
||||
// const demo = {
|
||||
// "test_path": {
|
||||
// "test_key": {
|
||||
// "path": "demo",
|
||||
// "key": "test",
|
||||
// metadata: {
|
||||
// args: {
|
||||
// name: z.string(),
|
||||
// age: z.number(),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } as const;
|
||||
|
||||
// // 方式1: 使用工厂函数创建(推荐)
|
||||
// const queryApi = createQueryApi({ query: new Query(), api: demo });
|
||||
|
||||
// // 现在调用时会有完整的类型推断
|
||||
// // data 参数会被推断为 { name?: string, age?: number }
|
||||
// queryApi.test_path.test_key({ name: "test", age: 18 });
|
||||
// // 也可以不传参数
|
||||
// queryApi.test_path.test_key();
|
||||
|
||||
// // 或者只传递 opts
|
||||
// queryApi.test_path.test_key(undefined, { timeout: 5000 });
|
||||
@@ -1,9 +1,9 @@
|
||||
import { adapter } from './adapter.ts';
|
||||
import { QueryWs, QueryWsOpts } from './ws.ts';
|
||||
import { Query, ClientQuery } from './query.ts';
|
||||
import { Query } from './query.ts';
|
||||
import { BaseQuery, QueryOptions, wrapperError } from './query.ts';
|
||||
|
||||
export { QueryOpts, QueryWs, ClientQuery, Query, QueryWsOpts, adapter, BaseQuery, wrapperError };
|
||||
export { QueryOpts, QueryWs, Query, QueryWsOpts, adapter, BaseQuery, wrapperError };
|
||||
export { QueryOptions }
|
||||
export type { DataOpts, Result, Data } from './query.ts';
|
||||
|
||||
|
||||
11
src/query.ts
11
src/query.ts
@@ -162,6 +162,7 @@ export class Query {
|
||||
*/
|
||||
async post<R = any, P = any>(body: Data & P, options?: DataOpts): Promise<Result<R>> {
|
||||
const url = options?.url || this.url;
|
||||
console.log('query post', url, body, options);
|
||||
const { headers, adapter, beforeRequest, afterResponse, timeout, ...rest } = options || {};
|
||||
const _headers = { ...this.headers, ...headers };
|
||||
const _adapter = adapter || this.adapter;
|
||||
@@ -301,13 +302,3 @@ export class BaseQuery<T extends Query = Query, R extends { queryChain?: any; qu
|
||||
return this.query.get(data, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* 前端调用后端QueryRouter, 默认路径 /client/router
|
||||
*/
|
||||
export class ClientQuery extends Query {
|
||||
constructor(opts?: QueryOpts) {
|
||||
super({ ...opts, url: opts?.url || '/client/router' });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user