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", "name": "@kevisual/query",
"devDependencies": { "devDependencies": {
"@kevisual/code-builder": "^0.0.6", "@kevisual/code-builder": "^0.0.6",
"@kevisual/router": "^0.0.75", "@kevisual/router": "^0.0.80",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"es-toolkit": "^1.44.0", "es-toolkit": "^1.44.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -18,7 +18,7 @@
"packages": { "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/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=="], "@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", "name": "@kevisual/query",
"version": "0.0.47", "version": "0.0.48",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "npm run clean && bun run bun.config.ts", "build": "npm run clean && bun run bun.config.ts",
@@ -19,7 +19,7 @@
"description": "", "description": "",
"devDependencies": { "devDependencies": {
"@kevisual/code-builder": "^0.0.6", "@kevisual/code-builder": "^0.0.6",
"@kevisual/router": "^0.0.75", "@kevisual/router": "^0.0.80",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"es-toolkit": "^1.44.0", "es-toolkit": "^1.44.0",

View File

@@ -1,4 +1,5 @@
import { toJSONSchema, fromJSONSchema } from '@kevisual/router/browser'
type RouteInfo = { type RouteInfo = {
path: string; path: string;
key: string; key: string;
@@ -44,6 +45,14 @@ export const createQueryByRoutes = (list: RouteInfo[]) => {
if (!obj[route.path]) { if (!obj[route.path]) {
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; obj[route.path][route.key] = route;
} }
const code = ` const code = `

View File

@@ -64,10 +64,19 @@ type InferType<T> =
T extends { properties: infer P } ? InferFromJSONSchema<T> : // 处理没有 type 但有 properties 的对象 T extends { properties: infer P } ? InferFromJSONSchema<T> : // 处理没有 type 但有 properties 的对象
T; T;
// 检查是否标记为可选
type IsOptional<T> = T extends { optional: true } ? true : false;
// 提取 args 对象,将每个 Zod schema 或 JSON Schema 转换为实际类型 // 提取 args 对象,将每个 Zod schema 或 JSON Schema 转换为实际类型
// 根据 optional 字段分离必需字段和可选字段
type ExtractArgsFromMetadata<T> = T extends { metadata?: { args?: infer A } } type ExtractArgsFromMetadata<T> = T extends { metadata?: { args?: infer A } }
? A extends Record<string, any> ? A extends Record<string, any>
? { [K in keyof A]: InferType<A[K]> } ? (
// 必需字段(没有 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
: never; : never;
@@ -75,7 +84,7 @@ type ExtractArgsFromMetadata<T> = T extends { metadata?: { args?: infer A } }
type ApiMethods<P extends { [path: string]: { [key: string]: Pos } }> = { type ApiMethods<P extends { [path: string]: { [key: string]: Pos } }> = {
[Path in keyof P]: { [Path in keyof P]: {
[Key in keyof P[Path]]: ( [Key in keyof P[Path]]: (
data?: Partial<ExtractArgsFromMetadata<P[Path][Key]>>, data?: ExtractArgsFromMetadata<P[Path][Key]>,
opts?: DataOpts opts?: DataOpts
) => ReturnType<Query['post']> ) => ReturnType<Query['post']>
} }
@@ -97,7 +106,7 @@ export class QueryApi<P extends { [path: string]: { [key: string]: Pos } } = {}>
// 使用泛型来推断类型 // 使用泛型来推断类型
post<T extends Pos>( post<T extends Pos>(
pos: T, pos: T,
data?: Partial<ExtractArgsFromMetadata<T>>, data?: ExtractArgsFromMetadata<T>,
opts?: DataOpts opts?: DataOpts
) { ) {
const _pos = pick(pos, ['path', 'key', 'id']); 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)) { 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']); const _pos = pick(pos, ['path', 'key', 'id']);
if (pos.metadata?.viewItem?.api?.url && !opts.url) { if (pos.metadata?.viewItem?.api?.url && !opts.url) {
opts.url = pos.metadata.viewItem.api.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'; import { createQueryApi } from '../src/query-api.ts';
// ============ 示例 1: 基础 JSON Schema 推断 ============ // ============ 示例 1: 基础 JSON Schema 推断 ============
@@ -9,7 +10,8 @@ const api1 = {
"metadata": { "metadata": {
"args": { "args": {
"userId": { "userId": {
"type": "string" "type": "string",
"optional": true
}, },
"includeProfile": { "includeProfile": {
"type": "boolean" "type": "boolean"
@@ -53,7 +55,8 @@ const api2 = {
"domain": { "domain": {
"type": "string" "type": "string"
} }
} },
"optional": true,
} }
} }
} }
@@ -63,7 +66,7 @@ const api2 = {
const queryApi2 = createQueryApi({ api: api2 }); const queryApi2 = createQueryApi({ api: api2 });
// ✅ 类型推断正常工作 - data 参数被推断为 { id: string, domain: string } // ✅ 类型推断正常工作 - data 参数被推断为 { id?: string, domain?: string }没有required字段所有字段可选
queryApi2.app_domain_manager.get({ queryApi2.app_domain_manager.get({
data: { data: {
id: '123', id: '123',
@@ -208,7 +211,8 @@ const api5 = {
"type": "string", "type": "string",
"enum": ["active", "inactive"] as const "enum": ["active", "inactive"] as const
} }
} },
// optional: true, // 单个 data 参数是可选的
// 没有 required 字段,所以所有字段都是可选的 // 没有 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", "path": "test",
"key": "test", "key": "test",
"description": "test route", "description": "test route",
"type": "route",
"middleware": [], "middleware": [],
"metadata": { "metadata": {
"args": { "args": {
"a": { "a": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "arg a", "description": "arg a",
"type": "string" "type": "string",
"optional": true
} }
} }
} }
@@ -40,16 +40,16 @@ const api = {
"path": "demo", "path": "demo",
"key": "d1", "key": "d1",
"description": "First demo route demonstrating string and number parameters", "description": "First demo route demonstrating string and number parameters",
"type": "route",
"middleware": [], "middleware": [],
"metadata": { "metadata": {
"args": { "args": {
"username": { "username": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "The username to be validated, must be between 3 and 20 characters",
"type": "string", "type": "string",
"minLength": 3, "minLength": 3,
"maxLength": 20, "maxLength": 20,
"description": "The username to be validated, must be between 3 and 20 characters" "optional": true
}, },
"age": { "age": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -94,7 +94,6 @@ const api = {
"path": "demo", "path": "demo",
"key": "d2", "key": "d2",
"description": "Second demo route for boolean and enum parameters", "description": "Second demo route for boolean and enum parameters",
"type": "route",
"middleware": [], "middleware": [],
"metadata": { "metadata": {
"args": { "args": {
@@ -145,7 +144,6 @@ const api = {
"path": "demo", "path": "demo",
"key": "d3", "key": "d3",
"description": "Third demo route handling array and optional parameters", "description": "Third demo route handling array and optional parameters",
"type": "route",
"middleware": [], "middleware": [],
"metadata": { "metadata": {
"args": { "args": {
@@ -182,7 +180,8 @@ const api = {
"description": "Priority level from 1 to 5, defaults to 3 if not specified", "description": "Priority level from 1 to 5, defaults to 3 if not specified",
"type": "number", "type": "number",
"minimum": 1, "minimum": 1,
"maximum": 5 "maximum": 5,
"optional": true
}, },
"keywords": { "keywords": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -208,7 +207,6 @@ const api = {
"path": "demo", "path": "demo",
"key": "d4", "key": "d4",
"description": "Fourth demo route with nested object parameters", "description": "Fourth demo route with nested object parameters",
"type": "route",
"middleware": [], "middleware": [],
"metadata": { "metadata": {
"args": { "args": {
@@ -309,7 +307,8 @@ const api = {
"city", "city",
"country" "country"
], ],
"additionalProperties": false "additionalProperties": false,
"optional": true
} }
} }
} }
@@ -329,7 +328,6 @@ const api = {
"path": "demo", "path": "demo",
"key": "d5", "key": "d5",
"description": "Fifth demo route with mixed complex parameters and validation", "description": "Fifth demo route with mixed complex parameters and validation",
"type": "route",
"middleware": [], "middleware": [],
"metadata": { "metadata": {
"args": { "args": {
@@ -434,14 +432,16 @@ const api = {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"default": false, "default": false,
"description": "Whether to include metadata in response", "description": "Whether to include metadata in response",
"type": "boolean" "type": "boolean",
"optional": true
}, },
"timeout": { "timeout": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Request timeout in milliseconds, between 1s and 30s", "description": "Request timeout in milliseconds, between 1s and 30s",
"type": "number", "type": "number",
"minimum": 1000, "minimum": 1000,
"maximum": 30000 "maximum": 30000,
"optional": true
}, },
"retry": { "retry": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -449,7 +449,8 @@ const api = {
"description": "Number of retry attempts on failure", "description": "Number of retry attempts on failure",
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,
"maximum": 5 "maximum": 5,
"optional": true
} }
} }
} }
@@ -463,7 +464,6 @@ const api = {
"path": "router", "path": "router",
"key": "list", "key": "list",
"description": "列出当前应用下的所有的路由信息", "description": "列出当前应用下的所有的路由信息",
"type": "route",
"middleware": [] "middleware": []
} }
} }

View File

@@ -24,7 +24,7 @@ app.route({
description: 'First demo route demonstrating string and number parameters', description: 'First demo route demonstrating string and number parameters',
metadata: { metadata: {
args: { 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'), 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'), 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'), 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({ const schema = z.object({
name: z.string().describe("The name of the person"), name: z.string().describe("The name of the person"),
age: z.number().int().min(0).describe("The age 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"), email: z.string().optional().describe("The email address of the person"),
}); })
const nameSchema = { name: z.string().optional().describe("The name of the person") }
console.log("JSON Schema for the person object:"); // 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( console.log(
JSON.stringify(toJSONSchema(schema), null, 2) JSON.stringify(jsonSchema, null, 2)
); );
console.log('shape', schema.shape); // console.log('shape', schema.shape);
console.log('shape name', schema.shape.name.toJSONSchema()); // console.log('shape name', schema.shape.name.toJSONSchema());
// const jsonSchema = toJSONSchema(schema); // 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>,所以无法在编译时推断出具体类型 // // schema2 的类型是 ZodSchema<any>,所以无法在编译时推断出具体类型
// // 这是 fromJSONSchema 的限制 - JSON Schema 转换会丢失 TypeScript 类型信息 // // 这是 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;
}