OpenAPI 文档
VextJS 内置 OpenAPI 文档自动生成功能。基于路由的 validate 和 docs 配置,框架自动生成 OpenAPI 3.0 规范 JSON,并通过 Vext Docs Renderer 提供默认 /docs 文档页。第三方文档工具请直接消费 /openapi.json。
快速开始
1. 启用 OpenAPI
在配置中开启 openapi.enabled:
// src/config/default.ts
export default {
port: 3000,
openapi: {
enabled: true,
},
};
2. 在路由中添加文档信息
// src/routes/users.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.get(
"/",
{
validate: {
query: {
page: "number:1-",
limit: "number:1-100",
},
},
docs: {
summary: "获取用户列表",
description: "分页获取所有用户信息",
},
},
async (req, res) => {
const { page = 1, limit = 20 } = req.valid("query");
const users = await app.services.user.findAll({ page, limit });
res.json(users);
},
);
app.post(
"/",
{
validate: {
body: {
name: "string:1-50!",
email: "email!",
age: "number:0-150?",
},
},
middlewares: ["audit-log"],
docs: {
summary: "创建用户",
},
},
async (req, res) => {
const data = req.valid("body");
const user = await app.services.user.create(data);
res.json(user, 201);
},
);
});
3. 访问文档
启动项目后,访问以下地址:
默认 Vext Docs UI 会把 HTTP API、Pages、Services、Utils、Models、已发现的 Components、Plugins、Middlewares 作为顶层入口,当前选中的顶层入口可以收缩/展开自己的左侧导航树。
HTTP API 与 Pages 会:
- 根据 OpenAPI path segment 生成递归导航;
- 保留
/api/v1/info 这类稳定资源路径段作为分类;
- 具体接口叶子优先显示
docs.summary,未配置 summary 时回退到接口地址;
- 把
{id} 这类动态 path 参数视为参数,而不是普通业务目录。
响应状态会以横向 tab 展示,内容区不再重复展示状态标题;本地 schema $ref 会展开为真实字段,object schema 不再显示人工 (root) 行。桌面端左侧侧栏会固定在视口内并独立滚动,可根据可见导航标签自动加宽,也支持手动拖拽并持久化宽度,内置 docs 资产会带版本标记,避免浏览器缓存遮住 renderer 更新。
顶部 header 会把搜索、UI 控件、分类筛选与 Authorize 分成清晰行;Overview 工作台会展示统计和 package 启动/build/验证命令。右侧 API/code/model/plugin/middleware 条目会使用独立 item shell 分隔,长页面连续阅读时更容易区分。
Pages 由 route handler 中直接调用 res.render() 或 res.renderError() 自动识别。Services / Utils / Components 从标准 JSDoc 生成,且不会 import 或执行用户代码。Models 会列出可识别的 model 文件,没有 JSDoc 时生成最小条目,有 JSDoc 时作为增强信息;根目录 model 会直接挂在 Models 下,不再出现人工 root 分类,嵌套 model 按源码目录分组。
默认 UI 会静态读取支持的 model definition 形态,展示 registry key、name、collection、connection、schema fields、enums、options、indexes、methods、hooks 和 usage,同样不会 import 或执行 model 代码。Plugins 与 Middlewares 会从 src/plugins、src/middlewares 扫描 JSDoc 与可静态推断的生命周期/bootstrap、app extension、middleware 类型、路由调用方式和源码链接。
Locales / Config / Styles / Preload 静态源码文档仍是可选高级来源,可以通过 openapi.docs.code.* 显式开启,但不属于默认顶层文档入口。本地 loopback 访问文档页时,code docs 条目可展示 Open source 链接并跳转到 vscode://file/...;非本地访问默认隐藏该链接。
路由级 docs.tags 已废弃并会被忽略,同时输出 warning;operation tags 会从路由 path/source 自动推断,并收进折叠 Metadata,不再作为主要 badge 铺开。
x-tagGroups 仅在显式配置 openapi.tagGroups 时作为原始 OpenAPI vendor extension 输出;内置文档导航不依赖它。存在 OpenAPI security schemes 时,UI 会展示接口鉴权状态,并提供全局 Authorize 控件供同源 Try it out 合并使用。
B26 进一步补齐主题与密度控制、Overview 工作台、搜索快捷键、类别过滤、命中高亮、桌面右侧大纲、endpoint/link/response/usage/source path 复制按钮和导航深链。动态 path 参数仍弱化展示,但当中间动态段后面还有稳定子资源时会保留层级,例如 /docs-nav/{id}/sdfs/sdfaf 会保留参数节点与后续资源层级。
B27 将 Try it out 升级为轻量请求控制台。每个接口可展示 server 选择、完整 URL 预览与 Copy URL,并用 Params、Headers、Body、Samples、History、Response 标签页收纳输入、样例、历史和响应。Query/Header 没有声明字段时保持紧凑空态,仍支持 raw fallback;Header 行会从 OpenAPI parameters[in=header] 自动生成,包括 validate.header。Headers 标签页同时展示 auth 状态和最终有效 headers 预览,让 Authorize 自动注入的请求头与手动覆盖关系放在同一个位置确认。Samples 标签页包含 cURL/browser fetch/Node fetch/Axios 代码样例,固定 Response 标签页保留 pretty/raw body 模式,并同时展示实际发送的 request headers 与 response headers,方便确认请求到底携带了什么。Axios 只是示例文本,Vext 不会把 Axios 加入运行时依赖。
B31 进一步优化小屏与大接口量场景。移动端使用带同步搜索和分类筛选的抽屉导航,窄屏下生成字段表格会切换为带字段标签的卡片行,Try it out 内部控件只在打开接口控制台时创建,HTTP API 长列表会增量渲染并提供 Load more,同时保留 deep link 目标的首屏可达性。
B32 增加多版本 / 多文档面的 source-aware 能力。当生成的 OpenAPI paths 中至少存在两个版本 source group,例如 /api/v1/**、/api/v2/**、/api/beta/**、/v1/**、/v2/**、/beta/** 时,Vext Docs 会自动展示有序的 All / API v1 / API v2 / API Beta 这类切换器。数字版本会排在 alpha、beta、rc 这类命名发布通道之前。
每个 source 会分别读取过滤后的 /_vext/docs/openapi.json?source=<id>、code.json?source=<id>、search.json?source=<id> 数据,因此当前 source 拥有独立的 Overview 统计、导航树、搜索状态、权限过滤后的接口集合和 deep link。
非 All source 默认只返回 OpenAPI 条目;只有该 source 显式配置 code.include / code.exclude 时,才会纳入 Code JSDoc 条目,避免全局 Services / Utils / Models 泄漏到单版本 API 文档面。既有单 source 的 #anchor 链接继续兼容;多 source 链接使用 #source=<id>&view=<view>&id=<anchor>。
如果自动版本识别不够,项目可以通过 openapi.docs.sources 显式定义文档面,source.access,包括 source.access.visible,也会作用于 source 切换器和 source-aware 数据端点。每个显式 source 仍需要 match,因为它定义 OpenAPI 数据作用域;纯 Code JSDoc source 可以使用 /sdk/** 这类稳定的非 API namespace,再通过 code.include / code.exclude 纳入对应代码文档。
B32 同时增强 Try it out 的真实项目接入能力。OpenAPI servers[].variables 会在 server 选择器旁渲染为控件,并参与 URL 预览、Copy URL、代码样例、历史记录和 Send 请求。
项目也可以通过 openapi.docs.tryItOut.hookScript 与 hookGlobal 配置浏览器端请求 hook;hookGlobal 只是浏览器查找名,只有配置了 hook script 或运行时全局对象暴露 beforeRequest / afterResponse 时才显示 hook 提示。
文档页会在 fetch 前后调用这些函数,合并 hook 返回的请求 header/body/URL 变更,并在 Response 标签页展示诊断信息。hook 只运行在浏览器文档页,Vext 不会为此 import 或执行后端项目代码。
多文档面配置
当 Public/Admin/Internal、版本或受众边界无法仅通过路径自动推断时,可以使用 openapi.docs.sources:
export default {
openapi: {
docs: {
sources: [
{
id: "public-v1",
label: "Public v1",
match: ["/api/v1/**"],
default: true,
},
{
id: "admin-v1",
label: "Admin v1",
match: ["/admin/v1/**"],
access: "admin",
},
{
id: "internal-v1",
label: "Internal v1",
match: ["/internal/v1/**"],
access: { visible: false },
},
{
id: "sdk",
label: "SDK",
match: ["/sdk/**"],
code: {
include: ["services/sdk", "models/*"],
exclude: ["*internal*"],
},
},
],
},
},
};
source.access 会作为 kind: "source" descriptor 传给 openapi.docs.access.resolver。source.access.visible: false 会在 resolver 执行前隐藏该 source。options.docs.access 会写入 x-vext-docs-access,并作为 kind: "operation" descriptor 的 access 字段传给同一个 resolver;visible: false 会直接隐藏该 operation,tryItOut: false 会禁用该 operation 的 Try it out。source.code.include / source.code.exclude 用于让非 All source 纳入 Code JSDoc 条目;不配置时,非 All source 只暴露 OpenAPI 条目。Code 过滤会同时匹配条目的 id、title 与 source file,因此 models/*、services/sdk/** 这类路径风格模式可用于常见源码范围。
Try it out 请求 Hook
hookScript 指向文档页会加载的浏览器脚本。脚本需要暴露 window[hookGlobal],并可实现 beforeRequest / afterResponse:
// public/docs-hook.js
window.VextDocsHooks = {
beforeRequest({ request, path, source }) {
return {
headers: {
...request.headers,
"x-docs-source": source && source.id ? source.id : "all",
"x-docs-signature": "demo-" + path,
},
};
},
afterResponse({ response }) {
return {
diagnostics: ["status: " + response.status],
};
},
};
export default {
openapi: {
docs: {
tryItOut: {
hookScript: "/docs-hook.js",
hookGlobal: "VextDocsHooks",
},
},
},
};
如果需要在生成后追加组织级扩展字段,可使用 OpenAPI hook。OpenAPIGenerator.generate() 仍保持同步,openapi:afterGenerate 也必须同步返回 patch:
// src/plugins/openapi-extra.ts
import { definePlugin } from "vextjs";
export default definePlugin({
name: "openapi-extra",
setup(app) {
app.hooks.on("openapi:afterGenerate", ({ document }) => ({
document: {
...(document as Record<string, unknown>),
"x-service-owner": "platform",
},
}));
},
});
文档配置
全局配置
在 config/default.ts 中配置 OpenAPI 全局信息:
// src/config/default.ts
export default {
openapi: {
enabled: true,
title: "My App API",
description: "我的应用程序 RESTful API 文档",
version: "1.0.0",
// OpenAPI JSON 路径
jsonPath: "/openapi.json",
// 代理剥离前缀时,外部工具使用的公开 OpenAPI 规范地址
// jsonPublicPath: '/admin/openapi.json',
// Vext Docs 配置
docs: {
path: "/docs",
// 代理剥离前缀时,浏览器可见的 docs 资产/数据前缀
// assetsPublicPath: "/admin/_vext/docs",
ui: {
title: "My App API",
defaultView: "overview",
theme: "system",
density: "comfortable",
},
code: {
enabled: "auto",
services: true,
utils: true,
models: true,
},
},
// API 服务器列表
servers: [
{ url: "http://localhost:3000", description: "本地开发" },
{ url: "https://api.myapp.com", description: "生产环境" },
],
// 标签定义(控制全局 tag 描述,默认文档页仍按 path segment 导航)
tags: [
{ name: "用户管理", description: "用户 CRUD 操作" },
{ name: "订单管理", description: "订单相关接口" },
{ name: "系统", description: "系统级接口" },
],
// 安全方案定义
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
apiKeyAuth: {
type: "apiKey",
in: "header",
name: "X-API-Key",
},
},
// 联系方式
contact: {
name: "API Support",
email: "support@myapp.com",
url: "https://myapp.com/support",
},
// 许可证
license: {
name: "Apache-2.0",
url: "https://www.apache.org/licenses/LICENSE-2.0",
},
},
};
apiKey 安全方案可以使用 in: "cookie",validate.cookie 也会渲染为 OpenAPI cookie 参数。浏览器 Try it out 不能直接设置受限的 Cookie header;如需手动 cookie 值,请使用同源浏览器 cookie 或 cURL 等 HTTP 客户端。
Code docs 会扫描 src/services、src/utils、配置后的 models 目录、src/frontend/components、src/plugins 和 src/middlewares,且不会 import 或执行用户代码。Services、Utils、Components 需要导出符号上存在标准 JSDoc;Models 会列出可识别的 model 文件,即使没有 JSDoc 也会生成最小条目,default export 上的 JSDoc 会作为增强信息。支持的 model definition 还会在默认 UI 中展示 schema fields、enums、options、indexes、methods、hooks 与 usage 示例。Plugins 会展示可推断的 plugin name、dependencies、lifecycle hooks、全局 middleware 注册、app extension 和 setup 使用方式;Middlewares 会展示可推断的 middleware/factory 类型和路由调用示例。vext start 运行构建产物时,如果项目根目录仍存在 <project>/src,Vext 会优先读取该源码目录以保留顶层 JSDoc 和本地源码跳转;如果部署环境没有源码树,则回退到运行时目录。
路由级文档配置
每个路由可以通过 options.docs 配置其 OpenAPI 文档信息:
app.post('/users', {
validate: { ... },
docs: {
// 接口摘要(一句话描述)
summary: '创建用户',
// 详细描述(支持 Markdown)
description: '创建一个新用户。\n\n**注意:** 邮箱必须唯一。',
// 操作标识(全局唯一,默认自动推断)
operationId: 'createUser',
// 是否已废弃
deprecated: false,
// 是否从文档中隐藏
hidden: false,
// 安全方案覆盖
security: [{ bearerAuth: [] }],
// 自定义响应定义
responses: {
201: {
description: '创建成功',
schema: { id: 'string', name: 'string', email: 'email' },
},
409: {
description: '邮箱已存在',
},
},
// 自定义扩展字段(x- 前缀)
extensions: {
'x-internal': true,
'x-rate-limit': '10/min',
},
},
}, handler);
x-rate-limit 只有在 rate-limit 对象中间件同时提供正数 max 和 window 时才会自动生成。字符串中间件、缺失 options、只提供部分字段或字段类型不合法时,Vext 不会输出空的 x-rate-limit,以免 OpenAPI 消费者误读限流契约。
docs 配置详解
summary — 接口摘要
一句话描述接口功能,显示在文档 UI 的接口列表中:
docs: {
summary: "获取用户列表";
}
description — 详细描述
支持 Markdown 格式的详细说明,展开接口时显示:
docs: {
summary: '创建用户',
description: `
创建一个新用户账户。
**前置条件:**
- 需要管理员权限
- 邮箱地址必须唯一
**返回值:**
- 成功时返回新创建的用户对象
- 邮箱冲突时返回 409 错误
`,
}
路由级 docs.tags 已废弃并会被忽略。Vext 现在会优先从路由 path 自动推断一个 operation tag,必要时才回退到 source file:
/admin/check-role-test/override → Admin
/api/v1/info → API v1
/api/beta/info → API Beta
/v1/info → API v1
/permission/roles/{id} → Permission
如果既有路由仍配置了 docs.tags,Vext 会忽略该值并输出废弃警告。请从路由定义中移除该字段,依赖 path/source 自动推断。
operationId — 操作标识
全局唯一的操作标识符。如果不指定,框架自动推断:
POST /users → operationId: 'createUsers'
GET /users → operationId: 'getUsers'
GET /users/:id → operationId: 'getUsersById'
PUT /users/:id → operationId: 'updateUsersById'
DELETE /users/:id → operationId: 'deleteUsersById'
// 手动指定
docs: {
operationId: "createNewUser";
}
operationId 必须在整个 OpenAPI 文档中保持唯一。Vext 在生成阶段会校验显式 docs.operationId 和自动推断值:重复的显式值、显式值撞上自动推断值、或不同路由自动推断出同一值都会直接报错。处理方式是为其中一条路由设置唯一的 docs.operationId,或调整路由 method/path 让自动推断结果不同。
hidden — 隐藏路由
不希望出现在文档中的路由(如内部接口):
app.get(
"/internal/metrics",
{
docs: { hidden: true },
},
handler,
);
app.get(
"/_health",
{
docs: { hidden: true },
},
handler,
);
deprecated — 标记废弃
标记接口为已废弃,在文档中会有删除线和废弃提示:
app.get(
"/v1/users",
{
docs: {
summary: "获取用户列表(已废弃)",
description: "请使用 `/v2/users` 替代",
deprecated: true,
},
},
handler,
);
security — 安全方案
新应用推荐用 RouteOptions.auth 声明路由保护。OpenAPI security 会优先从 auth 生成,然后才回退到历史的 middleware 名称推断:
// config/default.ts
export default {
openapi: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
},
},
};
// src/auth/route-guards.ts
import type { RouteOptions } from "vextjs";
export function requireAuth(options: RouteOptions): RouteOptions {
return {
...options,
middlewares: ["auth"],
auth: { required: true, security: "bearerAuth" },
};
}
app.get(
"/profile",
requireAuth({
docs: { summary: "获取当前用户" },
}),
handler,
);
如果需要显式指定安全方案,可使用 auth: { security: "bearerAuth" }。auth: { required: false } 且没有 roles、scopes、permissions 或 check 时,OpenAPI 会把该路由标记为公开;如果同时声明这些授权规则,运行时仍会要求认证,OpenAPI 也会输出认证 security。config.openapi.guardSecurityMap 仍兼容只声明 middleware 的历史路由,但不应再作为新 Auth 示例的主路径。
手动覆盖:
// 无需认证(即使有 auth 中间件)
docs: {
security: [];
}
// 指定特定安全方案
docs: {
security: [{ apiKeyAuth: [] }];
}
responses — 响应定义
自定义路由的响应文档。key 为 HTTP 状态码:
docs: {
responses: {
200: {
description: '成功返回用户列表',
schema: {
id: 'string',
name: 'string',
email: 'email',
role: 'admin|user',
},
},
401: {
description: '未认证',
},
403: {
description: '权限不足',
},
500: {
description: '服务器内部错误',
},
},
}
响应 schema 使用与 validate 相同的 DSL 语法,自动转换为 JSON Schema。
响应示例
docs: {
responses: {
200: {
description: '用户详情',
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
},
},
404: {
description: '用户不存在',
example: {
code: 40001,
message: '用户不存在',
requestId: 'xxx',
},
},
},
}
多响应示例
docs: {
responses: {
200: {
description: '用户详情',
examples: {
admin: {
summary: '管理员用户',
value: { id: '1', name: 'Admin', role: 'admin' },
},
regular: {
summary: '普通用户',
value: { id: '2', name: 'User', role: 'user' },
},
},
},
},
}
自定义 Content-Type
docs: {
responses: {
200: {
description: 'CSV 导出文件',
contentType: 'text/csv',
},
},
}
响应头
docs: {
responses: {
200: {
description: '成功',
headers: {
'X-Total-Count': {
description: '总记录数',
schema: { type: 'integer' },
},
'X-Page': {
description: '当前页码',
schema: { type: 'integer' },
},
},
},
},
}
validate 与文档的自动联动
路由中的 validate 规则会自动映射到 OpenAPI 文档,无需重复编写:
app.get(
"/users",
{
validate: {
query: {
page: "number:1-",
limit: "number:1-100",
status: "active|inactive|banned",
keyword: "string?",
},
},
docs: { summary: "获取用户列表" },
},
handler,
);
自动生成的 OpenAPI 参数:
validate.body 的规则自动映射为 requestBody(JSON schema):
app.post(
"/users",
{
validate: {
body: {
name: "string:1-50!",
email: "email!",
age: "number:0-150?",
},
},
},
handler,
);
生成的 requestBody schema:
{
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
"email": { "type": "string", "format": "email" },
"age": { "type": "number", "minimum": 0, "maximum": 150 }
}
}
字段级业务描述使用显式、无全局副作用的 builder,生成器会把它输出为 OpenAPI schema 的 description:
import { schemaAdapter } from "vextjs";
app.post(
"/translate",
{
validate: {
body: {
content: schemaAdapter
.compileField("string:1-20000!")
.description("待翻译文本,长度 1-20000 个字符"),
targetLanguages: [
{
code: schemaAdapter
.compileField("string:1-64!")
.description("目标语言代码"),
},
],
format: schemaAdapter
.compileField("enum:plain_text,preserve_line_breaks")
.description("输出格式"),
},
},
},
handler,
);
生成的 requestBody schema 中会包含:
{
"type": "object",
"required": ["content"],
"properties": {
"content": {
"type": "string",
"minLength": 1,
"maxLength": 20000,
"description": "待翻译文本,长度 1-20000 个字符"
},
"format": {
"type": "string",
"enum": ["plain_text", "preserve_line_breaks"],
"description": "输出格式"
}
}
}
使用 RouteOptions.multipart.files 声明文件上传路由,生成器自动输出 multipart/form-data requestBody。
app.post(
"/upload/avatar",
{
middlewares: ["upload"],
multipart: {
files: {
avatar: {
description: "头像图片(JPEG/PNG,最大 5MB)",
required: true,
},
},
},
docs: {
summary: "上传头像",
},
},
handler,
);
生成的 OpenAPI 片段:
{
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["avatar"],
"properties": {
"avatar": {
"type": "string",
"format": "binary",
"description": "头像图片(JPEG/PNG,最大 5MB)"
}
}
}
}
}
}
}
required: true 同时是运行时契约和 OpenAPI 提示。请求缺少 required 上传字段时,Vext 会返回 400 并包含缺失字段名。optional 和未声明的文件字段允许上传,除非违反 maxFiles、maxFileSize 或 allowedMimeTypes。
和 validate.body 的关系
multipart.files 和 validate.body 互斥。同时配置时,multipart.files 优先。
按环境控制
建议在开发环境启用文档,生产环境关闭:
// src/config/default.ts
export default {
openapi: {
enabled: true,
title: "My App API",
docs: {
path: "/docs",
ui: {
title: "My App API",
},
},
},
};
// src/config/production.ts
export default {
openapi: {
enabled: false, // 生产环境关闭文档
},
};
如果生产环境需要保留 API 文档(只读参考):
// src/config/production.ts
export default {
openapi: {
enabled: true,
docs: {
path: "/docs",
access: {
mode: "visibility-only",
},
},
},
};
visibility-only 会保持公开 /openapi.json 完整,但 Vext Docs 页面、文档 OpenAPI 数据、config source 数据、code docs、search 数据和菜单会收到按可见性过滤后的数据。若隐藏的 operations 或 code docs 也必须从 canonical docs data 中移除,应使用 enforce。
自定义文档路径
通过 docs.path 和 jsonPath 修改两个端点的注册路径。docsPath 仍作为兼容字段保留,但新项目推荐使用 docs.path:
export default {
openapi: {
enabled: true,
docs: {
path: "/api-docs", // 文档: http://localhost:3000/api-docs
},
jsonPath: "/api/spec.json", // JSON: http://localhost:3000/api/spec.json
},
};
反向代理路径前缀场景
当应用部署在反向代理后,需要根据代理是否剥离前缀分两种情况处理。
情况一:代理剥离前缀(proxy_pass 末尾带 /)
# Nginx:/admin/* → vext(剥离 /admin 前缀)
location /admin/ {
proxy_pass http://127.0.0.1:3000/;
}
此时 vext 收到的请求路径已去掉 /admin,路由注册无需修改。内置 docs 页面会从 /_vext/docs/*.json 读取 source-aware 数据,所以这些浏览器可见的资产和数据地址也需要带上公开的 /admin 前缀。jsonPublicPath 仍可作为外部工具和元数据使用的 OpenAPI 公开规范地址,但它不是内置 source-aware docs UI 的主要数据端点。
需要通过 docs.assetsPublicPath 配置浏览器可见的 docs 资产/数据前缀,同时保留 docs.assetsPath 作为 vext 内部注册路径:
// config/production.ts
export default {
openapi: {
enabled: true,
// vext 内部路由保持默认
jsonPath: "/openapi.json",
// 外部工具和链接使用的公开 OpenAPI 规范地址
jsonPublicPath: "/admin/openapi.json",
docs: {
path: "/docs",
// 代理剥离 /admin 后 vext 实际收到的内部路径
assetsPath: "/_vext/docs",
// 浏览器看到的公开路径
assetsPublicPath: "/admin/_vext/docs",
},
},
};
请求链路:
浏览器 GET /admin/docs
→ Nginx 剥离 /admin → vext GET /docs → 返回 Vext Docs HTML
→ 浏览器 fetch /admin/_vext/docs/config.json
→ Nginx 剥离 /admin → vext GET /_vext/docs/config.json ✅
→ 浏览器 fetch /admin/_vext/docs/openapi.json?source=all
→ Nginx 剥离 /admin → vext GET /_vext/docs/openapi.json?source=all ✅
情况二:代理透传前缀(proxy_pass 末尾不带 /)
# Nginx:/admin/* → vext(保留 /admin 前缀透传)
location /admin/ {
proxy_pass http://127.0.0.1:3000;
}
此时 vext 收到的请求路径仍带 /admin,需要同步配置端点路径。浏览器公开路径与 vext 内部路径一致,所以无需配置 assetsPublicPath 或 jsonPublicPath:
// config/production.ts
export default {
openapi: {
enabled: true,
jsonPath: "/admin/openapi.json",
docs: {
path: "/admin/docs",
assetsPath: "/admin/_vext/docs",
},
},
};
两种情况对比
servers — 文档交互地址
servers 是写入 OpenAPI 规范文档本身的元数据字段,与端点注册路径无关。它用于告诉文档 UI 或第三方工具发起交互请求时使用哪个基础地址。
默认行为(不配置时):
{ "url": "/", "description": "Current server" }
相对路径 / 会自动跟随当前页面的域名,绝大多数情况下默认值已够用。
需要显式配置的场景:
- 文档页面和 API 不在同一个域(跨域)
- 希望在文档 UI 或第三方工具中提供多环境切换能力
export default {
openapi: {
enabled: true,
servers: [
{ url: "https://sit-api.example.com/admin", description: "SIT 环境" },
{ url: "https://api.example.com/admin", description: "生产环境" },
],
},
};
配置后,支持 servers 的文档 UI 或工具可以让用户手动切换目标环境。
Vext Docs 会把这些 servers[] 条目作为 Try it out 的 Server 列表,并默认选择第一条有效 server。固定本地或部署端点建议直接配置带端口的完整 URL,例如 http://127.0.0.1:3000;servers[].variables 更适合环境名、区域、租户或 API 版本等真正需要切换的片段,存在时才会渲染为可编辑控件,并参与 URL 预览、Copy URL、代码示例和 Send 请求。Same origin 选项默认只在没有配置 OpenAPI servers 时自动出现;可以通过 openapi.docs.tryItOut.sameOrigin 设置为 true 或 false 强制显示或隐藏。openapi.docs.tryItOut.defaultServer 支持 "first"、"same-origin"、"custom" 或精确 server URL,用于指定初始选中项。openapi.docs.tryItOut.customServer 默认开启,用户可以在浏览器里临时填写其他环境地址,不需要改项目配置。
导入外部 OpenAPI
Vext 默认文档页聚焦当前应用生成的 OpenAPI 文档。如果需要把多个外部 OpenAPI 文档聚合到同一个 UI,请在 Vext 外部使用文档平台或独立 UI,并让它们直接读取各服务的 /openapi.json。Vext 不暴露第三方 docs renderer hook,也不会安装文档 UI 包。
与第三方工具集成
导出 OpenAPI 规范
访问 http://localhost:3000/openapi.json 获取完整的 OpenAPI 3.0 JSON 文件,可用于:
- Postman — 导入 API 集合
- Insomnia — 导入 API 工作区
- 代码生成 — 使用
openapi-generator 生成客户端 SDK
- API 网关 — 导入到 Kong、AWS API Gateway 等
- 文档平台 — 导入到 Stoplight、ReadMe 等
示例:生成 TypeScript 客户端
npx openapi-generator-cli generate \
-i http://localhost:3000/openapi.json \
-g typescript-fetch \
-o ./generated/api-client
文档最佳实践
1. 始终提供 summary
summary 是接口在文档列表中最重要的标识,应简洁明了:
// ✅ 好的 summary
docs: {
summary: "获取用户列表";
}
docs: {
summary: "创建订单";
}
docs: {
summary: "上传用户头像";
}
// ❌ 不好的 summary
docs: {
summary: "这个接口用于获取系统中所有用户的列表数据";
} // 太长
docs: {
summary: "GET users";
} // 没有价值
2. 使用一致的标签
统一使用中文或英文标签,并在全局 tags 中预定义顺序和描述:
// ✅ 在 config 中统一定义
openapi: {
tags: [
{ name: '认证', description: '登录、注册、Token 管理' },
{ name: '用户', description: '用户 CRUD' },
{ name: '订单', description: '订单管理' },
{ name: '系统', description: '健康检查、配置信息' },
],
}
3. 为错误响应添加文档
常见的错误码应在 responses 中说明:
docs: {
summary: '创建用户',
responses: {
201: { description: '创建成功' },
400: { description: '请求参数错误' },
401: { description: '未认证' },
409: { description: '邮箱已存在' },
422: { description: '参数校验失败' },
},
}
4. 隐藏内部接口
框架内部或运维使用的接口应标记为 hidden:
// 健康检查、指标、调试接口等
app.get("/health", { docs: { hidden: true } }, handler);
app.get("/metrics", { docs: { hidden: true } }, handler);
app.get("/debug/config", { docs: { hidden: true } }, handler);
5. 善用 deprecated
API 版本迭代时,使用 deprecated 而非直接删除旧接口:
// v1 接口标记废弃
app.get(
"/v1/users",
{
docs: {
summary: "获取用户列表 (v1)",
deprecated: true,
description: "此接口已废弃,请使用 `GET /v2/users`",
},
},
handler,
);
// v2 新接口
app.get(
"/v2/users",
{
docs: {
summary: "获取用户列表",
},
},
handler,
);
多级目录示例
VextJS 的文件路由支持多层嵌套目录,每一级目录自动映射为 URL 路径段。默认 Vext Docs 页面会使用这些 OpenAPI path segment 生成递归 API 导航,保留稳定资源路径段作为分类,并在该目录下展示具体接口叶子。接口叶子优先使用 docs.summary,没有 summary 时回退到接口地址;{id} 这类动态 path 参数会按参数处理,不作为普通业务目录强化展示。operation tags 会从路由 path/source 自动推断,并作为轻量元数据 badge 展示;显式 x-tagGroups 只作为 vendor extension 元数据保留。
目录结构
src/routes/
├── index.ts # → /
├── api/
│ └── v1/
│ ├── index.ts # → /api/v1
│ ├── users.ts # → /api/v1/users
│ ├── users/
│ │ └── [id]/
│ │ └── orders.ts # → /api/v1/users/:id/orders
│ └── admin/
│ ├── dashboard.ts # → /api/v1/admin/dashboard
│ └── users.ts # → /api/v1/admin/users
└── webhooks/
└── stripe.ts # → /webhooks/stripe
路径映射对照
只有需要给自动推断的 operation tags 增加描述时,才需要在配置中预定义全局 tags;默认导航仍以 path segment 为准:
// src/config/default.ts
export default {
port: 3000,
openapi: {
enabled: true,
title: "My App API",
version: "2.0.0",
tags: [{ name: "API v1", description: "版本 1 API 接口" }],
},
};
各路由文件
routes/api/v1/users.ts — 用户公开接口
// src/routes/api/v1/users.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /api/v1/users → 用户列表
app.get(
"/",
{
validate: {
query: {
page: "number:1-",
limit: "number:1-50",
role: "admin|user?",
},
},
docs: {
summary: "获取用户列表",
},
},
async (req, res) => {
const filters = req.valid("query");
const users = await app.services.user.findAll(filters);
res.json(users);
},
);
// GET /api/v1/users/:id → 用户详情
app.get(
"/:id",
{
validate: { param: { id: "string!" } },
docs: {
summary: "获取用户详情",
responses: {
200: { description: "用户信息" },
404: { description: "用户不存在" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
if (!user) app.throw(404, "user.not_found");
res.json(user);
},
);
});
routes/api/v1/users/[id]/orders.ts — 用户订单(多级动态参数)
// src/routes/api/v1/users/[id]/orders.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /api/v1/users/:id/orders → 该用户的订单列表
app.get(
"/",
{
validate: {
param: { id: "string!" },
query: {
status: "pending|paid|shipped|completed?",
limit: "number:1-100",
},
},
docs: {
summary: "获取用户订单列表",
description: "获取指定用户的所有订单,支持按状态筛选。",
responses: {
200: { description: "订单列表" },
404: { description: "用户不存在" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const filters = req.valid("query");
const orders = await app.services.order.findByUserId(id, filters);
res.json(orders);
},
);
// GET /api/v1/users/:id/orders/:orderId → 订单详情
app.get(
"/:orderId",
{
validate: {
param: { id: "string!", orderId: "string!" },
},
docs: {
summary: "获取订单详情",
},
},
async (req, res) => {
const { id, orderId } = req.valid("param");
const order = await app.services.order.findOne(id, orderId);
if (!order) app.throw(404, "order.not_found");
res.json(order);
},
);
});
routes/api/v1/admin/dashboard.ts — 管理后台
// src/routes/api/v1/admin/dashboard.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /api/v1/admin/dashboard/stats → 统计数据
app.get(
"/stats",
{
middlewares: [
"auth",
{ name: "check-role", options: { roles: ["admin"] } },
],
docs: {
summary: "获取仪表盘统计",
responses: {
200: {
description: "统计数据",
example: {
totalUsers: 1024,
activeToday: 256,
totalOrders: 8192,
revenue: 99999.99,
},
},
401: { description: "未认证" },
403: { description: "权限不足" },
},
},
},
async (_req, res) => {
const stats = await app.services.dashboard.getStats();
res.json(stats);
},
);
});
routes/api/v1/admin/users.ts — 管理后台用户管理
// src/routes/api/v1/admin/users.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /api/v1/admin/users → 管理员查看所有用户
app.get(
"/",
{
middlewares: [
"auth",
{ name: "check-role", options: { roles: ["admin"] } },
],
validate: {
query: {
page: "number:1-",
limit: "number:1-100",
status: "active|banned|suspended?",
},
},
docs: {
summary: "管理员查看用户列表",
description: "管理员专用,支持按用户状态筛选,返回完整用户信息。",
},
},
async (req, res) => {
const filters = req.valid("query");
const users = await app.services.user.adminFindAll(filters);
res.json(users);
},
);
// PATCH /api/v1/admin/users/:id/ban → 封禁用户
app.patch(
"/:id/ban",
{
middlewares: [
"auth",
{ name: "check-role", options: { roles: ["admin"] } },
],
validate: {
param: { id: "string!" },
body: { reason: "string:1-500!" },
},
docs: {
summary: "封禁用户",
responses: {
200: { description: "封禁成功" },
404: { description: "用户不存在" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const { reason } = req.valid("body");
await app.services.user.ban(id, reason);
res.json({ success: true });
},
);
});
routes/webhooks/stripe.ts — 第三方回调
// src/routes/webhooks/stripe.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// POST /webhooks/stripe → Stripe 事件回调
app.post(
"/",
{
validate: {
header: { "stripe-signature": "string!" },
},
docs: {
summary: "Stripe Webhook 回调",
description: "接收 Stripe 支付事件通知。需要验证签名。",
responses: {
200: { description: "处理成功" },
400: { description: "签名验证失败" },
},
},
},
async (req, res) => {
const signature = req.valid("header")["stripe-signature"];
await app.services.payment.handleStripeWebhook(req.body, signature);
res.json({ received: true });
},
);
});
生成的 OpenAPI 路径
以上目录结构最终自动生成以下 OpenAPI 路径。默认 Vext Docs 侧栏按路径段导航,tags 作为接口元数据保留:
多级目录最佳实践
- 用目录层级表达 URL 结构:
api/v1/admin/ 自动映射为 /api/v1/admin/ 前缀,无需手动拼接
- 动态参数用
[param] 目录:users/[id]/orders.ts 自动变为 /users/:id/orders,文件内的 param 校验会出现在 OpenAPI 文档中
- operation tag 自动推断:路由级
docs.tags 已废弃并会被忽略;Vext 从路由 path/source 自动推断 operation tags,默认文档页仍按路径段导航
- 文件名即路由:无需
app.group() 或手动注册路由前缀,目录结构就是路由结构
:::
标签分组(x-tagGroups)
OpenAPI 3.x 规范的 tags 是一维扁平列表,不原生支持嵌套层级。当路由数量较多时,所有 tags 在文档侧边栏中平铺并列,不便于导航。
VextJS 只有在显式配置 openapi.tagGroups 时才会透传 x-tagGroups。内置 Vext Docs renderer 的默认侧栏主导航会优先使用 OpenAPI path segment 生成递归树,因此 x-tagGroups 只是原始 OpenAPI vendor extension 元数据,不是 Vext Docs 的导航能力。
默认行为
VextJS 默认不生成 x-tagGroups。内置 Vext Docs renderer 会使用 OpenAPI path segment 作为递归侧栏的真相源,因此自动 tag 分组并不必要,也容易生成 General / Admin 这类误导性分组。
路由级 docs.tags 已废弃并会被忽略。如果交付链路里的其他 OpenAPI 工具需要 x-tagGroups,可以在配置中显式指定 tagGroups,并确保分组里的名称匹配自动推断出的 operation tags 或全局 openapi.tags:
// src/config/app.ts
export default {
port: 3000,
openapi: {
enabled: true,
title: "My API",
version: "1.0.0",
// 给 OpenAPI 工具显式透传 vendor extension
tagGroups: [
{
name: "Public API",
tags: ["API v1"],
},
{
name: "Integration",
tags: ["Webhooks"],
},
],
// 可选:给自动推断的 operation tags 增加描述。
tags: [
{ name: "API v1", description: "版本 1 API 接口" },
{ name: "Webhooks", description: "第三方回调" },
],
},
};
:::warning
只有配置了 tagGroups 时,框架才会输出 x-tagGroups。请确保每个分组内的 tag 名称都能匹配 operation tag 或全局 tags 定义,避免消费该 OpenAPI 文档的工具出现未分组或不可见的标签。
效果对比
与热重载的兼容性
在 dev 模式下,热重载(soft reload)会自动重新生成 OpenAPI spec。如果配置了 openapi.tagGroups,显式 x-tagGroups 会随 spec 一起再次输出:
- 路由文件变更 → 触发热重载
- 创建新的 adapter 实例
- 重新加载路由 + 收集路由元信息
- 重新生成 OpenAPI spec(配置了
tagGroups 时包含显式 x-tagGroups)
- 在新 adapter 上重新注册
/docs 和 /openapi.json 端点
无需重启 dev server,刷新文档页面即可看到更新后的分组。
完整示例
// src/routes/orders.ts
import { defineRoutes, type RouteOptions } from "vextjs";
function requireAuth(options: RouteOptions): RouteOptions {
return {
...options,
middlewares: ["auth"],
auth: { required: true, security: "bearerAuth" },
};
}
export default defineRoutes((app) => {
// 获取订单列表
app.get(
"/",
requireAuth({
validate: {
query: {
page: "number:1-",
limit: "number:1-50",
status: "pending|paid|shipped|completed|cancelled",
startDate: "date?",
endDate: "date?",
},
},
docs: {
summary: "获取订单列表",
description: "分页获取当前用户的订单列表,支持按状态和日期范围筛选。",
responses: {
200: {
description: "订单列表",
headers: {
"X-Total-Count": {
description: "总订单数",
schema: { type: "integer" },
},
},
},
},
},
}),
async (req, res) => {
const filters = req.valid("query");
const orders = await app.services.order.findAll(filters);
res.json(orders);
},
);
// 创建订单
app.post(
"/",
requireAuth({
validate: {
body: {
productId: "string!",
quantity: "number:1-99!",
shippingAddress: "string:1-200!",
couponCode: "string?",
},
},
docs: {
summary: "创建订单",
responses: {
201: {
description: "订单创建成功",
example: {
orderId: "ord_abc123",
status: "pending",
total: 99.99,
},
},
400: { description: "库存不足或优惠券无效" },
401: { description: "未认证" },
},
},
}),
async (req, res) => {
const data = req.valid("body");
const order = await app.services.order.create(data);
res.json(order, 201);
},
);
// 取消订单
app.post(
"/:id/cancel",
requireAuth({
validate: {
param: { id: "string!" },
body: { reason: "string:1-500?" },
},
docs: {
summary: "取消订单",
responses: {
200: { description: "取消成功" },
400: { description: "订单状态不允许取消" },
404: { description: "订单不存在" },
},
},
}),
async (req, res) => {
const { id } = req.valid("param");
const { reason } = req.valid("body");
await app.services.order.cancel(id, reason);
res.json({ success: true });
},
);
});
下一步
- 了解 参数校验 的 DSL 语法如何映射到 OpenAPI
- 学习 配置 中 OpenAPI 的完整选项
- 查看 Adapter 架构 了解不同 Adapter 下的文档行为
- 探索 测试 如何验证 API 文档的准确性