chore: bump @kevisual/router to version 0.0.80, update QueryApi to handle optional fields in metadata args, and enhance JSON Schema conversion

This commit is contained in:
2026-02-18 12:58:44 +08:00
parent 73a9868c19
commit 05dace0c79
10 changed files with 183 additions and 37 deletions

View File

@@ -6,7 +6,7 @@
"name": "@kevisual/query",
"devDependencies": {
"@kevisual/code-builder": "^0.0.6",
"@kevisual/router": "^0.0.75",
"@kevisual/router": "^0.0.80",
"@types/node": "^25.2.3",
"es-toolkit": "^1.44.0",
"typescript": "^5.9.3",
@@ -18,7 +18,7 @@
"packages": {
"@kevisual/code-builder": ["@kevisual/code-builder@0.0.6", "", { "bin": { "code-builder": "bin/code.js", "builder": "bin/code.js" } }, "sha512-0aqATB31/yw4k4s5/xKnfr4DKbUnx8e3Z3BmKbiXTrc+CqWiWTdlGe9bKI9dZ2Df+xNp6g11W4xM2NICNyyCCw=="],
"@kevisual/router": ["@kevisual/router@0.0.75", "", { "dependencies": { "es-toolkit": "^1.44.0" } }, "sha512-WBDRKMjNYTP7ymkUUtiQwWYIcqnc+TGo3rFuRze8ovYV2UN5cQxIkIfsDbgWOdV1/v9b57gtiJvJRqWjCBWKRg=="],
"@kevisual/router": ["@kevisual/router@0.0.80", "", { "dependencies": { "es-toolkit": "^1.44.0" } }, "sha512-rVwi6Yf411bnNm2x94lMm+s4Csw0Yb7u/aj+VJJ59iouAYhjLuL7Rs1EcARhnQf47cegBJi6zozfGHgLsLHN2w=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],

View File

@@ -1,6 +1,6 @@
{
"name": "@kevisual/query",
"version": "0.0.47",
"version": "0.0.48",
"type": "module",
"scripts": {
"build": "npm run clean && bun run bun.config.ts",
@@ -19,7 +19,7 @@
"description": "",
"devDependencies": {
"@kevisual/code-builder": "^0.0.6",
"@kevisual/router": "^0.0.75",
"@kevisual/router": "^0.0.80",
"@types/node": "^25.2.3",
"typescript": "^5.9.3",
"es-toolkit": "^1.44.0",

View File

@@ -1,4 +1,5 @@
import { toJSONSchema, fromJSONSchema } from '@kevisual/router/browser'
type RouteInfo = {
path: string;
key: string;
@@ -44,6 +45,14 @@ export const createQueryByRoutes = (list: RouteInfo[]) => {
if (!obj[route.path]) {
obj[route.path] = {};
}
if (route.metadata?.args) {
const args = route.metadata.args;
if (args?.$schema) {
// 将 args 转换为 JSON Schema
const jsonSchema = fromJSONSchema(args);
route.metadata.args = toJSONSchema(jsonSchema);
}
}
obj[route.path][route.key] = route;
}
const code = `

View File

@@ -64,18 +64,27 @@ type InferType<T> =
T extends { properties: infer P } ? InferFromJSONSchema<T> : // 处理没有 type 但有 properties 的对象
T;
// 检查是否标记为可选
type IsOptional<T> = T extends { optional: true } ? true : false;
// 提取 args 对象,将每个 Zod schema 或 JSON Schema 转换为实际类型
// 根据 optional 字段分离必需字段和可选字段
type ExtractArgsFromMetadata<T> = T extends { metadata?: { args?: infer A } }
? A extends Record<string, any>
? { [K in keyof A]: InferType<A[K]> }
: never
? (
// 必需字段(没有 optional: true
{ [K in keyof A as IsOptional<A[K]> extends true ? never : K]: InferType<A[K]> } &
// 可选字段(有 optional: true
{ [K in keyof A as IsOptional<A[K]> extends true ? K : never]?: 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]>>,
data?: ExtractArgsFromMetadata<P[Path][Key]>,
opts?: DataOpts
) => ReturnType<Query['post']>
}
@@ -97,7 +106,7 @@ export class QueryApi<P extends { [path: string]: { [key: string]: Pos } } = {}>
// 使用泛型来推断类型
post<T extends Pos>(
pos: T,
data?: Partial<ExtractArgsFromMetadata<T>>,
data?: ExtractArgsFromMetadata<T>,
opts?: DataOpts
) {
const _pos = pick(pos, ['path', 'key', 'id']);
@@ -121,7 +130,7 @@ export class QueryApi<P extends { [path: string]: { [key: string]: Pos } } = {}>
}
for (const [key, pos] of Object.entries(methods)) {
that[path][key] = (data?: Partial<ExtractArgsFromMetadata<typeof pos>>, opts: DataOpts = {}) => {
that[path][key] = (data?: ExtractArgsFromMetadata<typeof pos>, opts: DataOpts = {}) => {
const _pos = pick(pos, ['path', 'key', 'id']);
if (pos.metadata?.viewItem?.api?.url && !opts.url) {
opts.url = pos.metadata.viewItem.api.url;

View File

@@ -1,3 +1,4 @@
import { optional } from 'zod';
import { createQueryApi } from '../src/query-api.ts';
// ============ 示例 1: 基础 JSON Schema 推断 ============
@@ -9,7 +10,8 @@ const api1 = {
"metadata": {
"args": {
"userId": {
"type": "string"
"type": "string",
"optional": true
},
"includeProfile": {
"type": "boolean"
@@ -53,7 +55,8 @@ const api2 = {
"domain": {
"type": "string"
}
}
},
"optional": true,
}
}
}
@@ -63,7 +66,7 @@ const api2 = {
const queryApi2 = createQueryApi({ api: api2 });
// ✅ 类型推断正常工作 - data 参数被推断为 { id: string, domain: string }
// ✅ 类型推断正常工作 - data 参数被推断为 { id?: string, domain?: string }没有required字段所有字段可选
queryApi2.app_domain_manager.get({
data: {
id: '123',
@@ -208,7 +211,8 @@ const api5 = {
"type": "string",
"enum": ["active", "inactive"] as const
}
}
},
// optional: true, // 单个 data 参数是可选的
// 没有 required 字段,所以所有字段都是可选的
}
}

13
test/query-test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { queryApi } from "./query.ts";
queryApi.test.test({
a: 'test'
})
queryApi.demo.d1({
name: 'Alice',
age: 30,
email: '',
count: 5,
// username: ''
})

View File

@@ -12,14 +12,14 @@ const api = {
"path": "test",
"key": "test",
"description": "test route",
"type": "route",
"middleware": [],
"metadata": {
"args": {
"a": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "arg a",
"type": "string"
"type": "string",
"optional": true
}
}
}
@@ -40,16 +40,16 @@ const api = {
"path": "demo",
"key": "d1",
"description": "First demo route demonstrating string and number parameters",
"type": "route",
"middleware": [],
"metadata": {
"args": {
"username": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "The username to be validated, must be between 3 and 20 characters",
"type": "string",
"minLength": 3,
"maxLength": 20,
"description": "The username to be validated, must be between 3 and 20 characters"
"optional": true
},
"age": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -94,7 +94,6 @@ const api = {
"path": "demo",
"key": "d2",
"description": "Second demo route for boolean and enum parameters",
"type": "route",
"middleware": [],
"metadata": {
"args": {
@@ -145,7 +144,6 @@ const api = {
"path": "demo",
"key": "d3",
"description": "Third demo route handling array and optional parameters",
"type": "route",
"middleware": [],
"metadata": {
"args": {
@@ -182,7 +180,8 @@ const api = {
"description": "Priority level from 1 to 5, defaults to 3 if not specified",
"type": "number",
"minimum": 1,
"maximum": 5
"maximum": 5,
"optional": true
},
"keywords": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -208,7 +207,6 @@ const api = {
"path": "demo",
"key": "d4",
"description": "Fourth demo route with nested object parameters",
"type": "route",
"middleware": [],
"metadata": {
"args": {
@@ -309,7 +307,8 @@ const api = {
"city",
"country"
],
"additionalProperties": false
"additionalProperties": false,
"optional": true
}
}
}
@@ -329,7 +328,6 @@ const api = {
"path": "demo",
"key": "d5",
"description": "Fifth demo route with mixed complex parameters and validation",
"type": "route",
"middleware": [],
"metadata": {
"args": {
@@ -434,14 +432,16 @@ const api = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": false,
"description": "Whether to include metadata in response",
"type": "boolean"
"type": "boolean",
"optional": true
},
"timeout": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Request timeout in milliseconds, between 1s and 30s",
"type": "number",
"minimum": 1000,
"maximum": 30000
"maximum": 30000,
"optional": true
},
"retry": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -449,7 +449,8 @@ const api = {
"description": "Number of retry attempts on failure",
"type": "integer",
"minimum": 0,
"maximum": 5
"maximum": 5,
"optional": true
}
}
}
@@ -463,7 +464,6 @@ const api = {
"path": "router",
"key": "list",
"description": "列出当前应用下的所有的路由信息",
"type": "route",
"middleware": []
}
}

View File

@@ -24,7 +24,7 @@ app.route({
description: 'First demo route demonstrating string and number parameters',
metadata: {
args: {
username: z.string().min(3).max(20).describe('The username to be validated, must be between 3 and 20 characters'),
username: z.string().min(3).max(20).optional().describe('The username to be validated, must be between 3 and 20 characters'),
age: z.number().min(18).max(100).describe('The age of the user, must be between 18 and 100'),
email: z.email().describe('The email address of the user for notification purposes'),
count: z.number().int().positive().describe('The number of items to process, must be a positive integer'),

View File

@@ -1,21 +1,32 @@
import z, { toJSONSchema } from "zod";
import z from "zod";
import { toJSONSchema, fromJSONSchema } from './test-schema.ts'
const schema = z.object({
name: z.string().describe("The name of the person"),
age: z.number().int().min(0).describe("The age of the person"),
email: z.string().optional().describe("The email address of the person"),
});
console.log("JSON Schema for the person object:");
})
const nameSchema = { name: z.string().optional().describe("The name of the person") }
// console.log("JSON Schema for the person object:");
// console.log(
// JSON.stringify(toJSONSchema(nameSchema), null, 2)
// );
console.log("\n--- 自定义 override ---");
const jsonSchema = toJSONSchema(nameSchema)
console.log(
JSON.stringify(toJSONSchema(schema), null, 2)
JSON.stringify(jsonSchema, null, 2)
);
console.log('shape', schema.shape);
console.log('shape name', schema.shape.name.toJSONSchema());
// console.log('shape', schema.shape);
// console.log('shape name', schema.shape.name.toJSONSchema());
// const jsonSchema = toJSONSchema(schema);
// const schema2 = z.fromJSONSchema(jsonSchema);
let schema2 = fromJSONSchema<false>(jsonSchema);
console.log("\n--- 从 JSON Schema 反向转换回 Zod schema ---");
console.log('schema2 nameSchema', schema2.name.safeParse("John Doe"));
// console.log('schema2', schema2.safeParse({ name: "John Doe", }));
// console.log('schema2 email', schema2.email.safeParse(undefined));
// console.log('schema2 age', schema2.age.safeParse(1));
// // schema2 的类型是 ZodSchema<any>,所以无法在编译时推断出具体类型
// // 这是 fromJSONSchema 的限制 - JSON Schema 转换会丢失 TypeScript 类型信息

100
test/test-schema.ts Normal file
View File

@@ -0,0 +1,100 @@
import { z } from "zod";
const extractArgs = (args: any) => {
if (args && typeof args === 'object' && typeof args.shape === 'object') {
return args.shape as z.ZodRawShape;
}
return args || {};
};
type ZodOverride = (opts: { jsonSchema: any; path: string[]; zodSchema: z.ZodTypeAny }) => void;
/**
* 剥离第一层schema转换为JSON Schema无论是skill还是其他的infer比纯粹的zod object schema更合适因为它可能包含其他的字段而不仅仅是schema
* @param args
* @returns
*/
export const toJSONSchema = (args: any, opts?: { mergeObject?: boolean, override?: ZodOverride }): { [key: string]: any } => {
const mergeObject = opts?.mergeObject ?? false;
if (!args) return {};
const _override = ({ jsonSchema, path, zodSchema }) => {
if (Array.isArray(path) && path.length > 0) {
return
}
const isOptional = (zodSchema as any).isOptional?.();
if (isOptional) {
// 添加自定义属性
jsonSchema.optional = true;
}
}
const isError = (keys: string[]) => {
const errorKeys: string[] = ["toJSONSchema", "def", "type", "parse"]
const hasErrorKeys = errorKeys.every(key => keys.includes(key));
return hasErrorKeys;
}
const override: any = opts?.override || _override;
if (mergeObject) {
if (typeof args === 'object' && typeof args.toJSONSchema === 'function') {
return args.toJSONSchema();
}
if (isError(Object.keys(args))) {
return {};
}
// 如果 mergeObject 为 true直接将整个对象转换为 JSON Schema
// 先检测是否是一个错误的 schema
const schema = z.object(args);
return schema.toJSONSchema();
}
// 如果 args 本身是一个 zod object schema先提取 shape
args = extractArgs(args);
let keys = Object.keys(args);
if (isError(keys)) {
console.error(`[toJSONSchema error]: 解析到的 schema 可能不正确包含了zod默认的value的schema. 请检查输入的 schema 是否正确。`);
args = {};
keys = [];
}
if (mergeObject) {
}
let newArgs: { [key: string]: any } = {};
for (let key of keys) {
const item = args[key] as z.ZodAny;
if (item && typeof item === 'object' && typeof item.toJSONSchema === 'function') {
newArgs[key] = item.toJSONSchema({ override });
} else {
newArgs[key] = args[key]; // 可能不是schema
}
}
return newArgs;
}
export const fromJSONSchema = <Merge extends boolean = false>(args: any = {}, opts?: { mergeObject?: boolean }) => {
let resultArgs: any = null;
const mergeObject = opts?.mergeObject ?? false;
if (args["$schema"] || (args.type === 'object' && args.properties && typeof args.properties === 'object')) {
// 可能是整个schema
const objectSchema = z.fromJSONSchema(args);
const extract = extractArgs(objectSchema);
const keys = Object.keys(extract);
const newArgs: { [key: string]: any } = {};
for (let key of keys) {
newArgs[key] = extract[key];
}
resultArgs = newArgs;
}
if (!resultArgs) {
const keys = Object.keys(args);
const newArgs: { [key: string]: any } = {};
for (let key of keys) {
const item = args[key];
// fromJSONSchema 可能会失败,所以先 optional等使用的时候再验证
newArgs[key] = z.fromJSONSchema(item)
if (item.optional) {
newArgs[key] = newArgs[key].optional();
}
}
resultArgs = newArgs;
}
if (mergeObject) {
resultArgs = z.object(resultArgs);
}
type ResultArgs = Merge extends true ? z.ZodObject<{ [key: string]: any }> : { [key: string]: z.ZodTypeAny };
return resultArgs as unknown as ResultArgs;
}