OpenAPI 文档

VextJS 内置 OpenAPI 文档自动生成功能。基于路由的 validatedocs 配置,框架自动生成 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. 访问文档

启动项目后,访问以下地址:

地址说明
http://localhost:3000/docsVext Docs 文档界面(HTTP API、Pages、services/utils/models/components/plugins/middlewares 文档)
http://localhost:3000/openapi.jsonOpenAPI JSON 规范文件

默认 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/pluginssrc/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 这类切换器。数字版本会排在 alphabetarc 这类命名发布通道之前。

每个 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.hookScripthookGlobal 配置浏览器端请求 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.resolversource.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/servicessrc/utils、配置后的 models 目录、src/frontend/componentssrc/pluginssrc/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 对象中间件同时提供正数 maxwindow 时才会自动生成。字符串中间件、缺失 options、只提供部分字段或字段类型不合法时,Vext 不会输出空的 x-rate-limit,以免 OpenAPI 消费者误读限流契约。

docs 配置详解

summary — 接口摘要

一句话描述接口功能,显示在文档 UI 的接口列表中:

docs: {
  summary: "获取用户列表";
}

description — 详细描述

支持 Markdown 格式的详细说明,展开接口时显示:

docs: {
  summary: '创建用户',
  description: `
创建一个新用户账户。

**前置条件:**
- 需要管理员权限
- 邮箱地址必须唯一

**返回值:**
- 成功时返回新创建的用户对象
- 邮箱冲突时返回 409 错误
  `,
}

tags — 已废弃接口标签

路由级 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 参数:

参数位置类型约束
pagequeryintegerminimum: 1
limitqueryintegerminimum: 1, maximum: 100
statusquerystringenum: ["active", "inactive", "banned"]
keywordquerystring

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": "输出格式"
    }
  }
}

文件上传路由(multipart/form-data)

使用 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 和未声明的文件字段允许上传,除非违反 maxFilesmaxFileSizeallowedMimeTypes

和 validate.body 的关系

multipart.filesvalidate.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.pathjsonPath 修改两个端点的注册路径。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 内部路径一致,所以无需配置 assetsPublicPathjsonPublicPath

// config/production.ts
export default {
  openapi: {
    enabled: true,
    jsonPath: "/admin/openapi.json",
    docs: {
      path: "/admin/docs",
      assetsPath: "/admin/_vext/docs",
    },
  },
};

两种情况对比

代理剥离前缀代理透传前缀
Nginx proxy_passhttp://127.0.0.1:3000/(末尾有 /http://127.0.0.1:3000(末尾无 /
jsonPath/openapi.json(默认)/admin/openapi.json
docs.path/docs(默认)/admin/docs
docs.assetsPath/_vext/docs(默认)/admin/_vext/docs
docs.assetsPublicPath/admin/_vext/docs必须配置无需配置
jsonPublicPath/admin/openapi.json(推荐用于公开 spec 链接)无需配置

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:3000servers[].variables 更适合环境名、区域、租户或 API 版本等真正需要切换的片段,存在时才会渲染为可编辑控件,并参与 URL 预览、Copy URL、代码示例和 Send 请求。Same origin 选项默认只在没有配置 OpenAPI servers 时自动出现;可以通过 openapi.docs.tryItOut.sameOrigin 设置为 truefalse 强制显示或隐藏。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

路径映射对照

文件路径URL 前缀说明
routes/index.ts/根路由(健康检查)
routes/api/v1/index.ts/api/v1API 版本入口
routes/api/v1/users.ts/api/v1/users用户公开接口
routes/api/v1/users/[id]/orders.ts/api/v1/users/:id/orders用户订单(动态参数嵌套)
routes/api/v1/admin/dashboard.ts/api/v1/admin/dashboard管理后台仪表盘
routes/api/v1/admin/users.ts/api/v1/admin/users管理后台用户管理
routes/webhooks/stripe.ts/webhooks/stripeStripe 回调

全局 tags 描述

只有需要给自动推断的 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 作为接口元数据保留:

OpenAPI 路径方法Tag来源文件
/api/v1/usersGETv1/用户api/v1/users.ts
/api/v1/users/{id}GETv1/用户api/v1/users.ts
/api/v1/users/{id}/ordersGETv1/用户订单api/v1/users/[id]/orders.ts
/api/v1/users/{id}/orders/{orderId}GETv1/用户订单api/v1/users/[id]/orders.ts
/api/v1/admin/dashboard/statsGETv1/管理后台api/v1/admin/dashboard.ts
/api/v1/admin/usersGETv1/管理后台api/v1/admin/users.ts
/api/v1/admin/users/{id}/banPATCHv1/管理后台api/v1/admin/users.ts
/webhooks/stripePOSTWebhookwebhooks/stripe.ts
多级目录最佳实践
  • 用目录层级表达 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 文档的工具出现未分组或不可见的标签。

效果对比

默认 Vext Docs显式 x-tagGroups
侧栏按 OpenAPI path segment 导航OpenAPI 文档包含显式 vendor extension 元数据
/api/v1/info 保留为资源分类Public API ▸ API v1 / Integration ▸ Webhooks
tags 作为接口元数据仅适合下游 OpenAPI 工具明确消费 x-tagGroups

与热重载的兼容性

在 dev 模式下,热重载(soft reload)会自动重新生成 OpenAPI spec。如果配置了 openapi.tagGroups,显式 x-tagGroups 会随 spec 一起再次输出:

  1. 路由文件变更 → 触发热重载
  2. 创建新的 adapter 实例
  3. 重新加载路由 + 收集路由元信息
  4. 重新生成 OpenAPI spec(配置了 tagGroups 时包含显式 x-tagGroups
  5. 在新 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 文档的准确性