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:
2026-02-17 21:39:41 +08:00
parent 7adedc0552
commit ecb69ba326
18 changed files with 1106 additions and 591 deletions

View File

@@ -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
View 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
View 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 });

View File

@@ -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';

View File

@@ -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' });
}
}