diff --git a/bun.lock b/bun.lock index e226e4e..8cc0de9 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 72aef4f..75a299a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/create-query/index.ts b/src/create-query/index.ts index 9d1da2b..12e30c4 100644 --- a/src/create-query/index.ts +++ b/src/create-query/index.ts @@ -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 = ` diff --git a/src/query-api.ts b/src/query-api.ts index 94df036..b9d1c21 100644 --- a/src/query-api.ts +++ b/src/query-api.ts @@ -64,18 +64,27 @@ type InferType = T extends { properties: infer P } ? InferFromJSONSchema : // 处理没有 type 但有 properties 的对象 T; +// 检查是否标记为可选 +type IsOptional = T extends { optional: true } ? true : false; + // 提取 args 对象,将每个 Zod schema 或 JSON Schema 转换为实际类型 +// 根据 optional 字段分离必需字段和可选字段 type ExtractArgsFromMetadata = T extends { metadata?: { args?: infer A } } ? A extends Record - ? { [K in keyof A]: InferType } - : never + ? ( + // 必需字段(没有 optional: true) + { [K in keyof A as IsOptional extends true ? never : K]: InferType } & + // 可选字段(有 optional: true) + { [K in keyof A as IsOptional extends true ? K : never]?: InferType } + ) + : never : never; // 类型映射:将 API 配置转换为方法签名 type ApiMethods

= { [Path in keyof P]: { [Key in keyof P[Path]]: ( - data?: Partial>, + data?: ExtractArgsFromMetadata, opts?: DataOpts ) => ReturnType } @@ -97,7 +106,7 @@ export class QueryApi

// 使用泛型来推断类型 post( pos: T, - data?: Partial>, + data?: ExtractArgsFromMetadata, opts?: DataOpts ) { const _pos = pick(pos, ['path', 'key', 'id']); @@ -121,7 +130,7 @@ export class QueryApi

} for (const [key, pos] of Object.entries(methods)) { - that[path][key] = (data?: Partial>, opts: DataOpts = {}) => { + that[path][key] = (data?: ExtractArgsFromMetadata, opts: DataOpts = {}) => { const _pos = pick(pos, ['path', 'key', 'id']); if (pos.metadata?.viewItem?.api?.url && !opts.url) { opts.url = pos.metadata.viewItem.api.url; diff --git a/test/json-schema-examples.ts b/test/json-schema-examples.ts index 76f7400..b70f6e2 100644 --- a/test/json-schema-examples.ts +++ b/test/json-schema-examples.ts @@ -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 字段,所以所有字段都是可选的 } } diff --git a/test/query-test.ts b/test/query-test.ts new file mode 100644 index 0000000..31163de --- /dev/null +++ b/test/query-test.ts @@ -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: '' +}) \ No newline at end of file diff --git a/test/query.ts b/test/query.ts index 8fd8615..455ec1e 100644 --- a/test/query.ts +++ b/test/query.ts @@ -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": [] } } diff --git a/test/router.ts b/test/router.ts index 07968c5..a53ad49 100644 --- a/test/router.ts +++ b/test/router.ts @@ -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'), diff --git a/test/schema.ts b/test/schema.ts index c6a3e0f..9f02cee 100644 --- a/test/schema.ts +++ b/test/schema.ts @@ -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(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,所以无法在编译时推断出具体类型 // // 这是 fromJSONSchema 的限制 - JSON Schema 转换会丢失 TypeScript 类型信息 diff --git a/test/test-schema.ts b/test/test-schema.ts new file mode 100644 index 0000000..94f9aa0 --- /dev/null +++ b/test/test-schema.ts @@ -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 = (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; +} \ No newline at end of file