mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 14:41:14 +00:00
Improve schema parsing and error handling in API debug tools
Enhances the TypeBox schema parser to better handle deep nesting, circular references, and union truncation, and adds error handling for schema parsing and default value generation in the OneBot API debug UI. Updates the display component to show clear messages for circular or truncated schemas, and improves robustness in HTTP debug command execution. Also synchronizes the ParsedSchema type in the Zod utility for consistency.
This commit is contained in:
parent
bf073b544b
commit
adabc4da46
@ -7,7 +7,7 @@ import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
|
||||
import { TbCode, TbMessageCode } from 'react-icons/tb';
|
||||
@ -59,16 +59,30 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
|
||||
const [responseHeight, setResponseHeight] = useState(240);
|
||||
const [storedHeight, setStoredHeight] = useLocalStorage('napcat_debug_response_height', 240);
|
||||
|
||||
const parsedRequest = parseTypeBox(data?.payload);
|
||||
// 使用 useMemo 缓存解析结果,避免每次渲染都重新解析
|
||||
const parsedRequest = useMemo(() => {
|
||||
try {
|
||||
return parseTypeBox(data?.payload);
|
||||
} catch (e) {
|
||||
console.error('Error parsing request schema:', e);
|
||||
return [];
|
||||
}
|
||||
}, [data?.payload]);
|
||||
|
||||
// 将返回值的 data 结构包装进 BaseResponseSchema 进行展示
|
||||
// 使用解构属性的方式重新构建对象,确保 parseTypeBox 能够识别为 object 类型
|
||||
const wrappedResponseSchema = Type.Object({
|
||||
...BaseResponseSchema.properties,
|
||||
data: data?.response || Type.Any({ description: '数据' })
|
||||
});
|
||||
|
||||
const parsedResponse = parseTypeBox(wrappedResponseSchema);
|
||||
const parsedResponse = useMemo(() => {
|
||||
try {
|
||||
const wrappedResponseSchema = Type.Object({
|
||||
...BaseResponseSchema.properties,
|
||||
data: data?.response || Type.Any({ description: '数据' })
|
||||
});
|
||||
return parseTypeBox(wrappedResponseSchema);
|
||||
} catch (e) {
|
||||
console.error('Error parsing response schema:', e);
|
||||
return [];
|
||||
}
|
||||
}, [data?.response]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
@ -166,7 +180,12 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
|
||||
if (data?.payloadExample) {
|
||||
setRequestBody(JSON.stringify(data.payloadExample, null, 2));
|
||||
} else {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
try {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
setRequestBody('{}');
|
||||
}
|
||||
}
|
||||
setResponseContent('');
|
||||
setResponseStatus(null);
|
||||
@ -320,7 +339,14 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
|
||||
)}
|
||||
</ChatInputModal>
|
||||
<Tooltip content="生成示例" closeDelay={0}>
|
||||
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2))}>
|
||||
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => {
|
||||
try {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
toast.error('生成示例失败');
|
||||
}
|
||||
}}>
|
||||
<TbCode size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@ -127,6 +127,20 @@ const SchemaContainer: React.FC<{
|
||||
};
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
|
||||
// 处理循环引用和截断的情况,直接显示提示而不继续递归
|
||||
if (schema.isCircularRef || schema.isTruncated) {
|
||||
return (
|
||||
<div className='mb-2 flex items-center gap-1 pl-5'>
|
||||
{schema.name && (
|
||||
<span className='text-default-400'>{schema.name}</span>
|
||||
)}
|
||||
<Chip size='sm' color='default' variant='flat'>
|
||||
{schema.description || '...'}
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
|
||||
@ -112,9 +112,17 @@ export default function HttpDebug () {
|
||||
const executeCommand = (commandId: string, mode: CommandPaletteExecuteMode) => {
|
||||
const api = commandId as OneBotHttpApiPath;
|
||||
const item = oneBotHttpApi[api];
|
||||
const body = item?.payloadExample
|
||||
? JSON.stringify(item.payloadExample, null, 2)
|
||||
: (item?.payload ? JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2) : '{}');
|
||||
let body = '{}';
|
||||
if (item?.payloadExample) {
|
||||
body = JSON.stringify(item.payloadExample, null, 2);
|
||||
} else if (item?.payload) {
|
||||
try {
|
||||
body = JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2);
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
body = '{}';
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectApi(api);
|
||||
// 确保请求参数可见
|
||||
|
||||
@ -9,6 +9,7 @@ export type ParsedSchema = {
|
||||
children?: ParsedSchema[];
|
||||
description?: string;
|
||||
isCircularRef?: boolean; // 标记循环引用
|
||||
isTruncated?: boolean; // 标记被截断
|
||||
};
|
||||
|
||||
// 定义基础响应结构 (TypeBox 格式)
|
||||
@ -21,95 +22,77 @@ export const BaseResponseSchema = Type.Object({
|
||||
echo: Type.Optional(Type.String({ description: '回显' })),
|
||||
});
|
||||
|
||||
// 最大解析深度,防止过深的嵌套
|
||||
const MAX_PARSE_DEPTH = 6;
|
||||
|
||||
/**
|
||||
* 获取 schema 的唯一标识符用于循环引用检测
|
||||
* 优先使用 $id 字符串标识符
|
||||
*/
|
||||
function getSchemaId (schema: TSchema): string | undefined {
|
||||
// 优先使用 $id
|
||||
if (schema.$id) {
|
||||
return schema.$id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 schema 中所有的 $id,用于预先检测可能的循环引用
|
||||
*/
|
||||
function collectSchemaIds (schema: TSchema, ids: Set<string> = new Set()): Set<string> {
|
||||
if (!schema) return ids;
|
||||
if (schema.$id) {
|
||||
ids.add(schema.$id);
|
||||
}
|
||||
if (schema.anyOf) {
|
||||
(schema.anyOf as TSchema[]).forEach(s => collectSchemaIds(s, ids));
|
||||
}
|
||||
if (schema.oneOf) {
|
||||
(schema.oneOf as TSchema[]).forEach(s => collectSchemaIds(s, ids));
|
||||
}
|
||||
if (schema.allOf) {
|
||||
(schema.allOf as TSchema[]).forEach(s => collectSchemaIds(s, ids));
|
||||
}
|
||||
if (schema.items) {
|
||||
collectSchemaIds(schema.items as TSchema, ids);
|
||||
}
|
||||
if (schema.properties) {
|
||||
Object.values(schema.properties).forEach(s => collectSchemaIds(s as TSchema, ids));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
// 最大解析深度
|
||||
const MAX_PARSE_DEPTH = 4;
|
||||
// 最大生成深度
|
||||
const MAX_GENERATE_DEPTH = 3;
|
||||
// anyOf/oneOf 最大解析选项数量
|
||||
const MAX_UNION_OPTIONS = 5;
|
||||
|
||||
export function parseTypeBox (
|
||||
schema: TSchema | undefined,
|
||||
name?: string,
|
||||
isRoot = true,
|
||||
visited: Set<string | TSchema> = new Set(),
|
||||
visitedIds: Set<string> = new Set(),
|
||||
depth = 0
|
||||
): ParsedSchema | ParsedSchema[] {
|
||||
// 基础检查
|
||||
if (!schema) {
|
||||
return isRoot ? [] : { name, type: 'unknown', optional: false };
|
||||
}
|
||||
|
||||
// 检查深度限制
|
||||
// 深度限制检查
|
||||
if (depth > MAX_PARSE_DEPTH) {
|
||||
return { name, type: 'object', optional: false, description: '(嵌套层级过深)', isCircularRef: true };
|
||||
return { name, type: 'object', optional: false, description: '...', isCircularRef: true };
|
||||
}
|
||||
|
||||
// 检查循环引用
|
||||
const schemaId = getSchemaId(schema);
|
||||
if (visited.has(schemaId)) {
|
||||
const refName = typeof schemaId === 'string' ? schemaId : (schema.description || 'object');
|
||||
return { name, type: 'object', optional: false, description: `(循环引用: ${refName})`, isCircularRef: true };
|
||||
// $id 循环引用检查
|
||||
const schemaId = schema.$id;
|
||||
if (schemaId && visitedIds.has(schemaId)) {
|
||||
return { name, type: 'object', optional: false, description: `(${schemaId})`, isCircularRef: true };
|
||||
}
|
||||
|
||||
// 对于复合类型,加入访问集合
|
||||
const isComplexType = schema.type === 'object' || schema.type === 'array' || schema.anyOf || schema.oneOf || schema.allOf;
|
||||
if (isComplexType) {
|
||||
visited = new Set(visited); // 创建副本避免影响兄弟节点的解析
|
||||
visited.add(schemaId);
|
||||
// 创建副本并添加当前 $id
|
||||
const newVisitedIds = new Set(visitedIds);
|
||||
if (schemaId) {
|
||||
newVisitedIds.add(schemaId);
|
||||
}
|
||||
|
||||
const description = schema.description;
|
||||
const optional = false;
|
||||
const type = schema.type;
|
||||
|
||||
// 常量值
|
||||
if (schema.const !== undefined) {
|
||||
return { name, type: 'value', value: schema.const, optional, description };
|
||||
}
|
||||
|
||||
// 枚举
|
||||
if (schema.enum) {
|
||||
return { name, type: 'enum', enum: schema.enum, optional, description };
|
||||
}
|
||||
|
||||
// 联合类型 (anyOf/oneOf) - 限制解析的选项数量
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const options = (schema.anyOf || schema.oneOf) as TSchema[];
|
||||
const children = options.map(opt => parseTypeBox(opt, undefined, false, visited, depth + 1) as ParsedSchema);
|
||||
const allOptions = (schema.anyOf || schema.oneOf) as TSchema[];
|
||||
// 只取前 MAX_UNION_OPTIONS 个选项
|
||||
const options = allOptions.slice(0, MAX_UNION_OPTIONS);
|
||||
const children = options.map(opt => parseTypeBox(opt, undefined, false, newVisitedIds, depth + 1) as ParsedSchema);
|
||||
|
||||
// 如果有更多选项被截断
|
||||
if (allOptions.length > MAX_UNION_OPTIONS) {
|
||||
children.push({
|
||||
name: undefined,
|
||||
type: 'object',
|
||||
optional: false,
|
||||
description: `... 还有 ${allOptions.length - MAX_UNION_OPTIONS} 个类型`,
|
||||
isTruncated: true
|
||||
});
|
||||
}
|
||||
return { name, type: 'union', children, optional, description };
|
||||
}
|
||||
|
||||
// allOf 交叉类型
|
||||
if (schema.allOf) {
|
||||
const parts = schema.allOf as TSchema[];
|
||||
const allProperties: Record<string, TSchema> = {};
|
||||
@ -125,17 +108,19 @@ export function parseTypeBox (
|
||||
});
|
||||
|
||||
if (canMerge) {
|
||||
return parseTypeBox({ ...schema, type: 'object', properties: allProperties, required: allRequired }, name, isRoot, visited, depth);
|
||||
return parseTypeBox({ ...schema, type: 'object', properties: allProperties, required: allRequired }, name, isRoot, newVisitedIds, depth);
|
||||
}
|
||||
const children = parts.map(part => parseTypeBox(part, undefined, false, visited, depth + 1) as ParsedSchema);
|
||||
const children = parts.slice(0, MAX_UNION_OPTIONS).map(part => parseTypeBox(part, undefined, false, newVisitedIds, depth + 1) as ParsedSchema);
|
||||
return { name, type: 'intersection', children, optional, description };
|
||||
}
|
||||
|
||||
// 对象类型
|
||||
if (type === 'object') {
|
||||
const properties = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
const children = Object.keys(properties).map(key => {
|
||||
const child = parseTypeBox(properties[key], key, false, visited, depth + 1) as ParsedSchema;
|
||||
const keys = Object.keys(properties);
|
||||
const children = keys.map(key => {
|
||||
const child = parseTypeBox(properties[key], key, false, newVisitedIds, depth + 1) as ParsedSchema;
|
||||
child.optional = !required.includes(key);
|
||||
return child;
|
||||
});
|
||||
@ -143,12 +128,17 @@ export function parseTypeBox (
|
||||
return { name, type: 'object', children, optional, description };
|
||||
}
|
||||
|
||||
// 数组类型
|
||||
if (type === 'array') {
|
||||
const items = schema.items as TSchema;
|
||||
const child = parseTypeBox(items, undefined, false, visited, depth + 1) as ParsedSchema;
|
||||
return { name, type: 'array', children: [child], optional, description };
|
||||
if (items) {
|
||||
const child = parseTypeBox(items, undefined, false, newVisitedIds, depth + 1) as ParsedSchema;
|
||||
return { name, type: 'array', children: [child], optional, description };
|
||||
}
|
||||
return { name, type: 'array', children: [], optional, description };
|
||||
}
|
||||
|
||||
// 基础类型
|
||||
if (type === 'string') return { name, type: 'string', optional, description };
|
||||
if (type === 'number' || type === 'integer') return { name, type: 'number', optional, description };
|
||||
if (type === 'boolean') return { name, type: 'boolean', optional, description };
|
||||
@ -157,67 +147,76 @@ export function parseTypeBox (
|
||||
return { name, type: type || 'unknown', optional, description };
|
||||
}
|
||||
|
||||
// 最大生成深度
|
||||
const MAX_GENERATE_DEPTH = 8;
|
||||
|
||||
export function generateDefaultFromTypeBox (
|
||||
schema: TSchema | undefined,
|
||||
visited: Set<string | TSchema> = new Set(),
|
||||
visitedIds: Set<string> = new Set(),
|
||||
depth = 0
|
||||
): any {
|
||||
// 基础检查
|
||||
if (!schema) return {};
|
||||
|
||||
// 检查深度限制
|
||||
// 深度限制
|
||||
if (depth > MAX_GENERATE_DEPTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查循环引用
|
||||
const schemaId = getSchemaId(schema);
|
||||
if (visited.has(schemaId)) {
|
||||
// 遇到循环引用,返回空值而不是继续递归
|
||||
// $id 循环引用检查
|
||||
const schemaId = schema.$id;
|
||||
if (schemaId && visitedIds.has(schemaId)) {
|
||||
return schema.type === 'array' ? [] : schema.type === 'object' ? {} : null;
|
||||
}
|
||||
|
||||
// 创建副本并添加当前 $id
|
||||
const newVisitedIds = new Set(visitedIds);
|
||||
if (schemaId) {
|
||||
newVisitedIds.add(schemaId);
|
||||
}
|
||||
|
||||
// 常量/默认值/枚举
|
||||
if (schema.const !== undefined) return schema.const;
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
if (schema.enum) return schema.enum[0];
|
||||
|
||||
// 联合类型 - 优先选择简单类型
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const options = (schema.anyOf || schema.oneOf) as TSchema[];
|
||||
// 优先选择非递归的简单类型
|
||||
const simpleOption = options.find(opt => opt.type === 'string' || opt.type === 'number' || opt.type === 'boolean');
|
||||
if (simpleOption) {
|
||||
return generateDefaultFromTypeBox(simpleOption, visited, depth + 1);
|
||||
// 优先找简单类型
|
||||
const stringOption = options.find(opt => opt.type === 'string');
|
||||
if (stringOption) return '';
|
||||
const numberOption = options.find(opt => opt.type === 'number' || opt.type === 'integer');
|
||||
if (numberOption) return 0;
|
||||
const boolOption = options.find(opt => opt.type === 'boolean');
|
||||
if (boolOption) return false;
|
||||
// 否则只取第一个
|
||||
if (options.length > 0) {
|
||||
return generateDefaultFromTypeBox(options[0], newVisitedIds, depth + 1);
|
||||
}
|
||||
return generateDefaultFromTypeBox(options[0], visited, depth + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = schema.type;
|
||||
|
||||
// 对象类型
|
||||
if (type === 'object') {
|
||||
// 对于复合类型,加入访问集合
|
||||
visited = new Set(visited);
|
||||
visited.add(schemaId);
|
||||
|
||||
const obj: any = {};
|
||||
const props = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
|
||||
// 只为必填字段和浅层字段生成默认值
|
||||
for (const key in props) {
|
||||
// 只为必填字段生成默认值,减少嵌套深度
|
||||
if (required.includes(key) || depth < 3) {
|
||||
obj[key] = generateDefaultFromTypeBox(props[key], visited, depth + 1);
|
||||
if (required.includes(key) || depth < 1) {
|
||||
obj[key] = generateDefaultFromTypeBox(props[key], newVisitedIds, depth + 1);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// 数组类型 - 返回空数组
|
||||
if (type === 'array') {
|
||||
// 数组类型返回空数组,避免在数组项中继续递归
|
||||
return [];
|
||||
}
|
||||
|
||||
// 基础类型
|
||||
if (type === 'string') return '';
|
||||
if (type === 'number' || type === 'integer') return 0;
|
||||
if (type === 'boolean') return false;
|
||||
|
||||
@ -25,13 +25,15 @@ import {
|
||||
export type LiteralValue = string | number | boolean | null;
|
||||
|
||||
export type ParsedSchema = {
|
||||
name?: string
|
||||
type: string | string[]
|
||||
optional: boolean
|
||||
value?: LiteralValue
|
||||
enum?: LiteralValue[]
|
||||
children?: ParsedSchema[]
|
||||
description?: string
|
||||
name?: string;
|
||||
type: string | string[];
|
||||
optional: boolean;
|
||||
value?: LiteralValue;
|
||||
enum?: LiteralValue[];
|
||||
children?: ParsedSchema[];
|
||||
description?: string;
|
||||
isCircularRef?: boolean; // 标记循环引用
|
||||
isTruncated?: boolean; // 标记被截断
|
||||
};
|
||||
|
||||
export function parse (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user