mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-11 15:30:25 +00:00
Merge pull request #1598 from faithleysath/feat/schema-enhancement-refactor
refactor(schema): 重构 schema 组件引用与 OpenAPI 生成流程,并补齐消息 union 类型
This commit is contained in:
@@ -17,7 +17,7 @@ export const OB11UserSchema = Type.Object({
|
||||
login_days: Type.Optional(Type.Number({ description: '登录天数' })),
|
||||
categoryName: Type.Optional(Type.String({ description: '分组名称' })),
|
||||
categoryId: Type.Optional(Type.Number({ description: '分组ID' })),
|
||||
}, { description: 'OneBot 11 用户信息' });
|
||||
}, { $id: 'OB11User', description: 'OneBot 11 用户信息' });
|
||||
|
||||
export const OB11GroupSchema = Type.Object({
|
||||
group_all_shut: Type.Number({ description: '是否全员禁言' }),
|
||||
@@ -26,7 +26,7 @@ export const OB11GroupSchema = Type.Object({
|
||||
group_name: Type.String({ description: '群名称' }),
|
||||
member_count: Type.Optional(Type.Number({ description: '成员人数' })),
|
||||
max_member_count: Type.Optional(Type.Number({ description: '最大成员人数' })),
|
||||
}, { description: 'OneBot 11 群信息' });
|
||||
}, { $id: 'OB11Group', description: 'OneBot 11 群信息' });
|
||||
|
||||
export const OB11GroupMemberSchema = Type.Object({
|
||||
group_id: Type.Number({ description: '群号' }),
|
||||
@@ -48,7 +48,7 @@ export const OB11GroupMemberSchema = Type.Object({
|
||||
shut_up_timestamp: Type.Optional(Type.Number({ description: '禁言截止时间戳' })),
|
||||
is_robot: Type.Optional(Type.Boolean({ description: '是否为机器人' })),
|
||||
qage: Type.Optional(Type.Number({ description: 'Q龄' })),
|
||||
}, { description: 'OneBot 11 群成员信息' });
|
||||
}, { $id: 'OB11GroupMember', description: 'OneBot 11 群成员信息' });
|
||||
|
||||
export const OB11NotifySchema = Type.Object({
|
||||
request_id: Type.Number({ description: '请求ID' }),
|
||||
@@ -60,7 +60,7 @@ export const OB11NotifySchema = Type.Object({
|
||||
checked: Type.Boolean({ description: '是否已处理' }),
|
||||
actor: Type.Number({ description: '操作者QQ' }),
|
||||
requester_nick: Type.String({ description: '申请者昵称' }),
|
||||
}, { description: 'OneBot 11 通知信息' });
|
||||
}, { $id: 'OB11Notify', description: 'OneBot 11 通知信息' });
|
||||
|
||||
export const lastestMessageSchema = Type.Object({
|
||||
self_id: Type.Number({ description: '发送者QQ号' }),
|
||||
@@ -82,7 +82,7 @@ export const lastestMessageSchema = Type.Object({
|
||||
post_type: Type.String({ description: '发布类型' }),
|
||||
group_id: Type.Number({ description: '群号' }),
|
||||
group_name: Type.String({ description: '群名称' }),
|
||||
}, { description: '最后一条消息' });
|
||||
}, { $id: 'OB11LatestMessage', description: '最后一条消息' });
|
||||
|
||||
export const OB11MessageSchema = Type.Intersect([
|
||||
lastestMessageSchema,
|
||||
@@ -95,4 +95,4 @@ export const OB11MessageSchema = Type.Intersect([
|
||||
likes_cnt: Type.String({ description: '点赞数' }),
|
||||
})),
|
||||
}, { description: 'OneBot 11 消息信息' })
|
||||
]);
|
||||
], { $id: 'OB11ActionMessage', description: 'OneBot 11 消息信息' });
|
||||
@@ -325,8 +325,11 @@ export const OB11MessageDataSchema = Type.Union([
|
||||
OB11MessageDiceSchema,
|
||||
OB11MessageRPSSchema,
|
||||
OB11MessageContactSchema,
|
||||
OB11MessageLocationSchema,
|
||||
OB11MessageJsonSchema,
|
||||
OB11MessageXmlSchema,
|
||||
OB11MessageMarkdownSchema,
|
||||
OB11MessageMiniAppSchema,
|
||||
OB11MessageNodeSchema,
|
||||
OB11MessageForwardSchema,
|
||||
OB11MessageOnlineFileSchema,
|
||||
|
||||
@@ -2,13 +2,16 @@ import { getAllHandlers } from '@/napcat-onebot/action/index';
|
||||
import { AutoRegisterRouter } from '@/napcat-onebot/action/auto-register';
|
||||
import { writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
import { OneBotAction, ActionExamples } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
import * as MessageSchemas from '@/napcat-onebot/types/message';
|
||||
import * as ActionSchemas from '@/napcat-onebot/action/schemas';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 基础类型 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ActionSchemaInfo {
|
||||
payload?: TSchema;
|
||||
@@ -21,52 +24,524 @@ interface ActionSchemaInfo {
|
||||
errorExamples?: Array<{ code: number, description: string; }>;
|
||||
}
|
||||
|
||||
type JsonObject = { [key: string]: unknown; };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const OPENAPI_OUTPUT_PATH = resolve(__dirname, 'openapi.json');
|
||||
const MISSING_REPORT_PATH = resolve(__dirname, 'missing_props.log');
|
||||
|
||||
export const actionSchemas: Record<string, ActionSchemaInfo> = {};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 日志工具 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 统一日志前缀,方便在构建日志中快速检索。
|
||||
*/
|
||||
const LOG_SCOPE = '[napcat-schema]';
|
||||
|
||||
function logSection (title: string) {
|
||||
console.log(`\n${LOG_SCOPE} ── ${title}`);
|
||||
}
|
||||
|
||||
function logInfo (message: string) {
|
||||
console.log(`${LOG_SCOPE} ℹ ${message}`);
|
||||
}
|
||||
|
||||
function logWarn (message: string) {
|
||||
console.warn(`${LOG_SCOPE} ⚠ ${message}`);
|
||||
}
|
||||
|
||||
function logSuccess (message: string) {
|
||||
console.log(`${LOG_SCOPE} ✅ ${message}`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* OpenAPI 基础组件(固定部分) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const BaseResponseSchema: JsonObject = {
|
||||
type: 'object',
|
||||
'x-schema-id': 'BaseResponse',
|
||||
properties: {
|
||||
status: { type: 'string', description: '状态 (ok/failed)' },
|
||||
retcode: { type: 'number', description: '返回码' },
|
||||
data: { description: '业务数据(具体结构由各接口定义)' },
|
||||
message: { type: 'string', description: '消息' },
|
||||
wording: { type: 'string', description: '提示' },
|
||||
stream: {
|
||||
type: 'string',
|
||||
description: '流式响应',
|
||||
enum: ['stream-action', 'normal-action']
|
||||
}
|
||||
},
|
||||
required: ['status', 'retcode']
|
||||
};
|
||||
|
||||
const EmptyDataSchema: JsonObject = {
|
||||
description: '无数据',
|
||||
type: 'null'
|
||||
};
|
||||
|
||||
const DEFAULT_SUCCESS_EXAMPLE_VALUE = {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: {},
|
||||
message: '',
|
||||
wording: '',
|
||||
stream: 'normal-action'
|
||||
} as const;
|
||||
|
||||
const DEFAULT_ERROR_EXAMPLE_DEFINITIONS = ActionExamples.Common.errors;
|
||||
|
||||
const SUCCESS_DEFAULT_EXAMPLE_KEY = 'Success_Default';
|
||||
|
||||
function isObjectRecord (value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isEmptyObject (value: unknown): value is Record<string, never> {
|
||||
return isObjectRecord(value) && Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
function isEmptyArray (value: unknown): value is [] {
|
||||
return Array.isArray(value) && value.length === 0;
|
||||
}
|
||||
|
||||
function isMeaninglessSuccessExampleData (value: unknown): boolean {
|
||||
return value === null || isEmptyObject(value) || isEmptyArray(value);
|
||||
}
|
||||
|
||||
function resolveCommonErrorExampleKey (error: { code: number, description: string; }): string | null {
|
||||
const matched = DEFAULT_ERROR_EXAMPLE_DEFINITIONS.find(
|
||||
item => item.code === error.code && item.description === error.description
|
||||
);
|
||||
return matched ? `Error_${matched.code}` : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 通用工具函数 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 深拷贝 schema,优先使用 structuredClone,失败时回落到 JSON 序列化。
|
||||
*/
|
||||
function cloneSchema<T> (schema: T): T {
|
||||
if (typeof globalThis.structuredClone === 'function') {
|
||||
try {
|
||||
return globalThis.structuredClone(schema);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return JSON.parse(JSON.stringify(schema)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 anyOf/oneOf 中,将“多个单值 enum 分支 + 可选 nullable 分支”压缩为单个 enum。
|
||||
*
|
||||
* 例:
|
||||
* - anyOf: [{ type:'string', enum:['a'] }, { type:'string', enum:['b'] }]
|
||||
* -> { type:'string', enum:['a','b'] }
|
||||
*/
|
||||
function collapseSingleValueEnumCombinator (items: unknown[]): Record<string, unknown> | null {
|
||||
const enumValues: unknown[] = [];
|
||||
let type: string | undefined;
|
||||
let nullable = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const branch = item as Record<string, unknown>;
|
||||
|
||||
// 兼容仅有 nullable 的分支
|
||||
if (branch['nullable'] === true && Object.keys(branch).length === 1) {
|
||||
nullable = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const branchEnum = branch['enum'];
|
||||
if (!Array.isArray(branchEnum) || branchEnum.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
enumValues.push(branchEnum[0]);
|
||||
|
||||
if (typeof branch['type'] === 'string') {
|
||||
if (!type) {
|
||||
type = branch['type'];
|
||||
} else if (type !== branch['type']) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enumValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merged: Record<string, unknown> = { enum: [...new Set(enumValues)] };
|
||||
if (type) {
|
||||
merged['type'] = type;
|
||||
}
|
||||
if (nullable) {
|
||||
merged['nullable'] = true;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 TypeBox/JSON-Schema 映射为 OpenAPI 3.1 兼容结构。
|
||||
*
|
||||
* 关键规则:
|
||||
* - $id -> x-schema-id(保留标识用于后续 $ref 替换)
|
||||
* - const -> enum:[const]
|
||||
* - type:'void' / type:'undefined' -> type:'null'
|
||||
* - nullable:true -> type 包含 'null'
|
||||
* - anyOf/oneOf 的简单 enum 分支做压缩
|
||||
*/
|
||||
function sanitizeSchemaForOpenAPI<T> (schema: T): T {
|
||||
const walk = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(walk);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, child] of Object.entries(obj)) {
|
||||
// 特殊处理 properties 容器:只遍历每个属性的 schema,避免将容器对象误判为 schema 元对象
|
||||
if (key === 'properties' && child && typeof child === 'object' && !Array.isArray(child)) {
|
||||
const cleanProps: Record<string, unknown> = {};
|
||||
for (const [propName, propSchema] of Object.entries(child as Record<string, unknown>)) {
|
||||
cleanProps[propName] = walk(propSchema);
|
||||
}
|
||||
next[key] = cleanProps;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '$id') {
|
||||
if (typeof child === 'string' && child.length > 0) {
|
||||
next['x-schema-id'] = child;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'const') {
|
||||
next['enum'] = [child];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'type' && typeof child === 'string') {
|
||||
if (child === 'void' || child === 'undefined') {
|
||||
next['type'] = 'null';
|
||||
} else {
|
||||
next['type'] = child;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'type' && Array.isArray(child)) {
|
||||
const types = child
|
||||
.filter((t): t is string => typeof t === 'string')
|
||||
.map(t => (t === 'void' || t === 'undefined') ? 'null' : t);
|
||||
|
||||
const normalizedTypes = [...new Set(types)];
|
||||
if (normalizedTypes.length === 0) {
|
||||
next['type'] = 'null';
|
||||
} else if (normalizedTypes.length === 1) {
|
||||
next['type'] = normalizedTypes[0];
|
||||
} else {
|
||||
next['type'] = normalizedTypes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((key === 'anyOf' || key === 'oneOf') && Array.isArray(child)) {
|
||||
const normalized = child.map(walk);
|
||||
const mergedEnum = collapseSingleValueEnumCombinator(normalized);
|
||||
if (mergedEnum) {
|
||||
Object.assign(next, mergedEnum);
|
||||
} else {
|
||||
next[key] = normalized;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
next[key] = walk(child);
|
||||
}
|
||||
|
||||
// OpenAPI 3.1:将 nullable 归一到 type 包含 null
|
||||
if (next['nullable'] === true) {
|
||||
const currentType = next['type'];
|
||||
if (typeof currentType === 'string') {
|
||||
next['type'] = currentType === 'null' ? 'null' : [currentType, 'null'];
|
||||
} else if (Array.isArray(currentType)) {
|
||||
const normalizedTypes = [
|
||||
...new Set(currentType
|
||||
.filter((t): t is string => typeof t === 'string')
|
||||
.map(t => (t === 'void' || t === 'undefined') ? 'null' : t)
|
||||
.concat('null'))
|
||||
];
|
||||
next['type'] = normalizedTypes.length === 1 ? normalizedTypes[0] : normalizedTypes;
|
||||
} else if (!('anyOf' in next) && !('oneOf' in next) && !('allOf' in next) && !('$ref' in next)) {
|
||||
next['type'] = 'null';
|
||||
}
|
||||
delete next['nullable'];
|
||||
}
|
||||
|
||||
// 兜底:仅有描述/元信息但缺少 type 时,补 object,避免严格校验失败
|
||||
if (
|
||||
!('type' in next)
|
||||
&& !('$ref' in next)
|
||||
&& !('anyOf' in next)
|
||||
&& !('oneOf' in next)
|
||||
&& !('allOf' in next)
|
||||
&& !('enum' in next)
|
||||
&& !('properties' in next)
|
||||
&& !('items' in next)
|
||||
) {
|
||||
const schemaMetaKeys = [
|
||||
'description', 'title', 'default', 'examples', 'example',
|
||||
'deprecated', 'readOnly', 'writeOnly', 'x-schema-id'
|
||||
];
|
||||
|
||||
if (schemaMetaKeys.some(key => key in next)) {
|
||||
next['type'] = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
return walk(schema) as T;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Schema 注册 & 引用替换逻辑 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 将模块中所有“含 $id 的导出 schema”注册到 components.schemas。
|
||||
*/
|
||||
function registerSchemasFromModule (
|
||||
openapi: JsonObject,
|
||||
source: Record<string, unknown>,
|
||||
sourceName: string
|
||||
) {
|
||||
const components = ((openapi['components'] as JsonObject)['schemas'] as JsonObject);
|
||||
let registeredCount = 0;
|
||||
let duplicatedCount = 0;
|
||||
|
||||
for (const exportedValue of Object.values(source)) {
|
||||
if (!exportedValue || typeof exportedValue !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const schema = cloneSchema(exportedValue) as JsonObject;
|
||||
const schemaId = typeof schema['$id'] === 'string' && (schema['$id'] as string).length > 0
|
||||
? schema['$id'] as string
|
||||
: '';
|
||||
|
||||
if (!schemaId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (components[schemaId]) {
|
||||
duplicatedCount += 1;
|
||||
logWarn(`发现重复 schema id(${sourceName}):${schemaId},将覆盖旧定义`);
|
||||
}
|
||||
|
||||
components[schemaId] = sanitizeSchemaForOpenAPI(schema);
|
||||
registeredCount += 1;
|
||||
}
|
||||
|
||||
logInfo(`${sourceName} 注册完成:${registeredCount} 个 schema,重复 ${duplicatedCount} 个`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 components.schemas 做去内联:
|
||||
* - 若子节点含 x-schema-id 且在 components.schemas 可命中
|
||||
* - 则替换为 $ref
|
||||
*
|
||||
* 注意:组件根节点不会替换为自身,避免根级自引用。
|
||||
*/
|
||||
function replaceComponentInlineSchemasWithRefs (openapi: JsonObject) {
|
||||
const components = openapi['components'] as JsonObject | undefined;
|
||||
const schemas = components?.['schemas'] as JsonObject | undefined;
|
||||
|
||||
if (!schemas || typeof schemas !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableSchemaIds = new Set(Object.keys(schemas));
|
||||
let replacedCount = 0;
|
||||
|
||||
const walk = (value: unknown, ownerSchemaId: string): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => walk(item, ownerSchemaId));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as JsonObject;
|
||||
const schemaId = obj['x-schema-id'];
|
||||
|
||||
if (
|
||||
typeof schemaId === 'string'
|
||||
&& schemaId !== ownerSchemaId
|
||||
&& availableSchemaIds.has(schemaId)
|
||||
) {
|
||||
replacedCount += 1;
|
||||
return { $ref: `#/components/schemas/${schemaId}` };
|
||||
}
|
||||
|
||||
const next: JsonObject = {};
|
||||
for (const [key, child] of Object.entries(obj)) {
|
||||
next[key] = walk(child, ownerSchemaId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
for (const [schemaId, schema] of Object.entries(schemas)) {
|
||||
schemas[schemaId] = walk(schema, schemaId);
|
||||
}
|
||||
|
||||
logInfo(`components 内联替换完成:${replacedCount} 处`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 paths 做去内联:
|
||||
* - 若节点含 x-schema-id 且在 components.schemas 可命中
|
||||
* - 则替换为 $ref
|
||||
*/
|
||||
function replacePathInlineSchemasWithRefs (openapi: JsonObject) {
|
||||
const paths = openapi['paths'];
|
||||
const components = openapi['components'] as JsonObject | undefined;
|
||||
const schemas = components?.['schemas'] as JsonObject | undefined;
|
||||
|
||||
if (!paths || typeof paths !== 'object' || !schemas || typeof schemas !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableSchemaIds = new Set(Object.keys(schemas));
|
||||
let replacedCount = 0;
|
||||
|
||||
const walk = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(walk);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as JsonObject;
|
||||
const schemaId = obj['x-schema-id'];
|
||||
|
||||
if (typeof schemaId === 'string' && availableSchemaIds.has(schemaId)) {
|
||||
replacedCount += 1;
|
||||
return { $ref: `#/components/schemas/${schemaId}` };
|
||||
}
|
||||
|
||||
const next: JsonObject = {};
|
||||
for (const [key, child] of Object.entries(obj)) {
|
||||
next[key] = walk(child);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
openapi['paths'] = walk(paths) as JsonObject;
|
||||
logInfo(`paths 内联替换完成:${replacedCount} 处`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action 收集逻辑 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 收集全部 action schema 信息。
|
||||
*/
|
||||
export function initSchemas () {
|
||||
const handlers = getAllHandlers(null as any, null as any);
|
||||
|
||||
handlers.forEach(handler => {
|
||||
if (handler.actionName && (handler.actionName as string) !== 'unknown') {
|
||||
const action = handler as OneBotAction<unknown, unknown>;
|
||||
actionSchemas[handler.actionName] = {
|
||||
payload: action.payloadSchema,
|
||||
return: action.returnSchema,
|
||||
summary: action.actionSummary,
|
||||
description: action.actionDescription,
|
||||
tags: action.actionTags,
|
||||
payloadExample: action.payloadExample,
|
||||
returnExample: action.returnExample,
|
||||
errorExamples: action.errorExamples
|
||||
};
|
||||
if (!handler.actionName || (handler.actionName as string) === 'unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = handler as OneBotAction<unknown, unknown>;
|
||||
actionSchemas[handler.actionName] = {
|
||||
payload: action.payloadSchema,
|
||||
return: action.returnSchema,
|
||||
summary: action.actionSummary,
|
||||
description: action.actionDescription,
|
||||
tags: action.actionTags,
|
||||
payloadExample: action.payloadExample,
|
||||
returnExample: action.returnExample,
|
||||
errorExamples: action.errorExamples
|
||||
};
|
||||
});
|
||||
AutoRegisterRouter.forEach((ActionClass) => {
|
||||
|
||||
AutoRegisterRouter.forEach(ActionClass => {
|
||||
const handler = new ActionClass(null as any, null as any);
|
||||
if (handler.actionName && (handler.actionName as string) !== 'unknown') {
|
||||
const action = handler as OneBotAction<unknown, unknown>;
|
||||
actionSchemas[handler.actionName] = {
|
||||
payload: action.payloadSchema,
|
||||
return: action.returnSchema,
|
||||
summary: action.actionSummary,
|
||||
description: action.actionDescription,
|
||||
tags: action.actionTags,
|
||||
payloadExample: action.payloadExample,
|
||||
returnExample: action.returnExample,
|
||||
errorExamples: action.errorExamples
|
||||
};
|
||||
if (!handler.actionName || (handler.actionName as string) === 'unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = handler as OneBotAction<unknown, unknown>;
|
||||
actionSchemas[handler.actionName] = {
|
||||
payload: action.payloadSchema,
|
||||
return: action.returnSchema,
|
||||
summary: action.actionSummary,
|
||||
description: action.actionDescription,
|
||||
tags: action.actionTags,
|
||||
payloadExample: action.payloadExample,
|
||||
returnExample: action.returnExample,
|
||||
errorExamples: action.errorExamples
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function generateOpenAPI () {
|
||||
try {
|
||||
initSchemas();
|
||||
} catch (e) {
|
||||
console.warn('Init schemas partial failure, proceeding with collected data...');
|
||||
}
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* OpenAPI 构建主流程 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const openapi: Record<string, unknown> = {
|
||||
openapi: '3.0.1',
|
||||
function createOpenAPIDocument (): Record<string, unknown> {
|
||||
const componentExamples: Record<string, unknown> = {
|
||||
[SUCCESS_DEFAULT_EXAMPLE_KEY]: {
|
||||
summary: '成功响应',
|
||||
value: DEFAULT_SUCCESS_EXAMPLE_VALUE
|
||||
}
|
||||
};
|
||||
|
||||
DEFAULT_ERROR_EXAMPLE_DEFINITIONS.forEach(error => {
|
||||
componentExamples[`Error_${error.code}`] = {
|
||||
summary: error.description,
|
||||
value: {
|
||||
status: 'failed',
|
||||
retcode: error.code,
|
||||
data: null,
|
||||
message: error.description,
|
||||
wording: error.description,
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'NapCat OneBot 11 HTTP API',
|
||||
description: 'NapCatOneBot11 HTTP POST 接口文档',
|
||||
@@ -81,39 +556,43 @@ export function generateOpenAPI () {
|
||||
],
|
||||
paths: {} as Record<string, unknown>,
|
||||
components: {
|
||||
schemas: {},
|
||||
schemas: {
|
||||
BaseResponse: BaseResponseSchema,
|
||||
EmptyData: EmptyDataSchema
|
||||
},
|
||||
examples: componentExamples,
|
||||
responses: {},
|
||||
securitySchemes: {}
|
||||
},
|
||||
servers: [],
|
||||
security: []
|
||||
};
|
||||
}
|
||||
|
||||
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
|
||||
if (!schemas.payload && !schemas.summary) continue;
|
||||
|
||||
const path = '/' + actionName;
|
||||
const cleanPayload = schemas.payload ? JSON.parse(JSON.stringify(schemas.payload)) : { type: 'object', properties: {} };
|
||||
const cleanReturn = schemas.return ? JSON.parse(JSON.stringify(schemas.return)) : { type: 'object', properties: {} };
|
||||
|
||||
// 构造响应示例
|
||||
const responseExamples: Record<string, any> = {
|
||||
'Success': {
|
||||
function buildResponseExamples (schemas: ActionSchemaInfo): Record<string, unknown> {
|
||||
const successData = schemas.returnExample ?? {};
|
||||
const examples: Record<string, any> = {
|
||||
Success: isMeaninglessSuccessExampleData(successData)
|
||||
? { $ref: `#/components/examples/${SUCCESS_DEFAULT_EXAMPLE_KEY}` }
|
||||
: {
|
||||
summary: '成功响应',
|
||||
value: {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: schemas.returnExample || {},
|
||||
data: successData,
|
||||
message: '',
|
||||
wording: '',
|
||||
stream: 'normal-action'
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (schemas.errorExamples) {
|
||||
schemas.errorExamples.forEach(error => {
|
||||
responseExamples['Error_' + error.code] = {
|
||||
if (schemas.errorExamples) {
|
||||
schemas.errorExamples.forEach(error => {
|
||||
const commonErrorKey = resolveCommonErrorExampleKey(error);
|
||||
examples[`Error_${error.code}`] = commonErrorKey
|
||||
? { $ref: `#/components/examples/${commonErrorKey}` }
|
||||
: {
|
||||
summary: error.description,
|
||||
value: {
|
||||
status: 'failed',
|
||||
@@ -124,23 +603,33 @@ export function generateOpenAPI () {
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// 默认提供一个通用错误
|
||||
responseExamples['Generic_Error'] = {
|
||||
summary: '通用错误',
|
||||
value: {
|
||||
status: 'failed',
|
||||
retcode: 1400,
|
||||
data: null,
|
||||
message: '请求参数错误或业务逻辑执行失败',
|
||||
wording: '请求参数错误或业务逻辑执行失败',
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
});
|
||||
return examples;
|
||||
}
|
||||
|
||||
examples['Generic_Error'] = {
|
||||
$ref: '#/components/examples/Error_1400'
|
||||
};
|
||||
|
||||
return examples;
|
||||
}
|
||||
|
||||
function appendActionPaths (openapi: Record<string, unknown>) {
|
||||
const paths = openapi['paths'] as Record<string, any>;
|
||||
|
||||
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
|
||||
if (!schemas.payload && !schemas.summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const paths = openapi['paths'] as Record<string, any>;
|
||||
const path = `/${actionName}`;
|
||||
const cleanPayload = schemas.payload
|
||||
? sanitizeSchemaForOpenAPI(cloneSchema(schemas.payload))
|
||||
: { type: 'object', properties: {} };
|
||||
const cleanReturn = schemas.return
|
||||
? sanitizeSchemaForOpenAPI(cloneSchema(schemas.return))
|
||||
: { $ref: '#/components/schemas/EmptyData' };
|
||||
|
||||
paths[path] = {
|
||||
post: {
|
||||
summary: schemas.summary || actionName,
|
||||
@@ -154,7 +643,7 @@ export function generateOpenAPI () {
|
||||
'application/json': {
|
||||
schema: cleanPayload,
|
||||
examples: {
|
||||
'Default': {
|
||||
Default: {
|
||||
summary: '默认请求示例',
|
||||
value: schemas.payloadExample || {}
|
||||
}
|
||||
@@ -168,18 +657,21 @@ export function generateOpenAPI () {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: '状态 (ok/failed)' },
|
||||
retcode: { type: 'number', description: '返回码' },
|
||||
data: { ...cleanReturn, description: '数据' },
|
||||
message: { type: 'string', description: '消息' },
|
||||
wording: { type: 'string', description: '提示' },
|
||||
stream: { type: 'string', description: '流式响应', enum: ['stream-action', 'normal-action'] }
|
||||
},
|
||||
required: ['status', 'retcode', 'data']
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/BaseResponse' },
|
||||
{
|
||||
type: 'object',
|
||||
required: ['data'],
|
||||
properties: {
|
||||
data: {
|
||||
...(typeof cleanReturn === 'object' && cleanReturn ? cleanReturn : {}),
|
||||
description: '业务数据'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
examples: responseExamples
|
||||
examples: buildResponseExamples(schemas)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,16 +680,46 @@ export function generateOpenAPI () {
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = resolve(__dirname, 'openapi.json');
|
||||
writeFileSync(outputPath, JSON.stringify(openapi, null, 2));
|
||||
console.log('OpenAPI schema (3.0.1 Format) generated at: ' + outputPath);
|
||||
export function generateOpenAPI () {
|
||||
logSection('开始生成 OpenAPI 文档');
|
||||
|
||||
try {
|
||||
initSchemas();
|
||||
logInfo(`已收集 action: ${Object.keys(actionSchemas).length} 个`);
|
||||
} catch {
|
||||
logWarn('初始化 schema 过程中出现部分失败,将继续使用已收集的数据');
|
||||
}
|
||||
|
||||
const openapi = createOpenAPIDocument();
|
||||
|
||||
logSection('注册组件 schema');
|
||||
registerSchemasFromModule(openapi as JsonObject, MessageSchemas, 'types/message.ts');
|
||||
registerSchemasFromModule(openapi as JsonObject, ActionSchemas, 'action/schemas.ts');
|
||||
|
||||
logSection('处理组件内联引用');
|
||||
replaceComponentInlineSchemasWithRefs(openapi as JsonObject);
|
||||
|
||||
logSection('构建 paths');
|
||||
appendActionPaths(openapi);
|
||||
|
||||
logSection('处理 paths 内联引用');
|
||||
replacePathInlineSchemasWithRefs(openapi as JsonObject);
|
||||
|
||||
writeFileSync(OPENAPI_OUTPUT_PATH, JSON.stringify(openapi, null, 2));
|
||||
logSuccess(`OpenAPI 生成完成:${OPENAPI_OUTPUT_PATH}`);
|
||||
|
||||
generateMissingReport();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 元数据缺失报告 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function generateMissingReport () {
|
||||
const missingReport: string[] = [];
|
||||
|
||||
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
|
||||
const missing: string[] = [];
|
||||
if (!schemas.summary) missing.push('actionSummary');
|
||||
@@ -206,18 +728,20 @@ function generateMissingReport () {
|
||||
if (schemas.returnExample === undefined) missing.push('returnExample');
|
||||
|
||||
if (missing.length > 0) {
|
||||
missingReport.push('[' + actionName + '] 缺失属性: ' + missing.join(', '));
|
||||
missingReport.push(`[${actionName}] 缺失属性: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const reportPath = resolve(__dirname, 'missing_props.log');
|
||||
if (missingReport.length > 0) {
|
||||
writeFileSync(reportPath, missingReport.join('\n'));
|
||||
console.warn('\n检查到 ' + missingReport.length + ' 个接口存在元数据缺失,报告已保存至: ' + reportPath);
|
||||
} else {
|
||||
if (existsSync(reportPath)) writeFileSync(reportPath, '');
|
||||
console.log('\n所有接口元数据已完整!');
|
||||
writeFileSync(MISSING_REPORT_PATH, missingReport.join('\n'));
|
||||
logWarn(`检查到 ${missingReport.length} 个接口元数据缺失,报告已写入:${MISSING_REPORT_PATH}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(MISSING_REPORT_PATH)) {
|
||||
writeFileSync(MISSING_REPORT_PATH, '');
|
||||
}
|
||||
logSuccess('所有接口元数据完整');
|
||||
}
|
||||
|
||||
generateOpenAPI();
|
||||
|
||||
Reference in New Issue
Block a user