Rocket.Chat REST API 的 AJV 双实例校验架构:ajv与ajvQuery的设计、选型与源码解析
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
JSON Schema 校验是 Rocket.Chat REST API 在运行时拦截非法请求、并在测试模式下校验响应一致性的核心防线。@rocket.chat/rest-typings包为此维护了两个配置不同、用途分明的 AJV 为主线,结合rest-typings、http-router与 meteor 服务端的源码实现,系统讲解这两个实例为何存在、各自适用场景、如何把.compile()出的 validator 挂载到路由选项上,以及nullable、oneOf/discriminator 等 schema 细节在“无类型强转”校验下会踩到哪些坑。读完你将能准确判断:一个参数校验器该用ajv还是ajvQuery,并能在测试中快速定位error-invalid-body一类问题的根因。
1. 背景:为什么需要两套 AJV 实例
在较早版本的 Rocket.Chat 中,全部端点共用同一个开启了coerceTypes: true的 AJV 实例。这在实践中带来一个隐蔽的问题:错误的类型会被静默“修正”。例如客户端把{ "rid": 12345 }(number)发到一个期望string的 body 字段,校验器会把12345悄悄强转为"12345"再通过校验,从而掩盖了调用方的类型错误。
@rocket.chat/rest-typings的变更记录对这一演进有明确描述:通过一次拆分,将单一 AJV 实例拆成两个——一个用于请求 body校验(期望严格、不改变数据类型),一个用于query 参数校验(由于 HTTP query string 天然全是字符串,必须允许类型强转)。参见 packages/rest-typings/CHANGELOG.md 中 8.3.0 小节对 PR #39559 的说明。
这一设计同时与 rocket.chat 面向 OpenAPI 的端点迁移(options.query/options.body/options.response三段式路由定义)深度绑定:validator 由 schema 编译而来,在请求进入路由 handler 之前先执行,从入口上保证“类型安全、响应结构可校验”。
提示:若只关心“现在该怎么写”,可直接跳到 第 4 节选型决策;想了解运行时机与错误码含义,请看 第 5 节 Router 调用链。
2. 实例定义:设计意图与当前源码对照
2.1 文档给出的定义与差异点
两个实例都创建于 Ajv.ts,配置基本相同,唯一的差异是coerceTypes:
| Option | ajv | ajvQuery |
|---|---|---|
coerceTypes | false | true |
allowUnionTypes | true | true |
code.source | true | true |
discriminator | true | true |
可以这样概括设计意图:
ajv:不改变数据类型,值必须已经符合 schema 期望的类型;ajvQuery:当 schema 期望number/integer/boolean时尝试进行类型强转(例如字符串"50"变成数字50)。
两者都注册了相同的自定义 formats 与自定义关键字,例如isNotEmpty。
2.2 当前仓库源码中的实际构造参数
需要提醒的是:截至本仓库当前 HEAD,Ajv.ts 中的实际构造参数与上表存在出入,源码为:
// packages/rest-typings/src/v1/Ajv.ts const ajv = new Ajv({ coerceTypes: true, allowUnionTypes: true, code: { source: true }, discriminator: true, }); /** AJV instance for query param validation; coerces types (e.g. string "50" → number, "c" → ["c"]) for URL query strings. */ const ajvQuery = new Ajv({ coerceTypes: 'array', allowUnionTypes: true, code: { source: true }, discriminator: true, });当前状态的两个要点:
ajvQuery的coerceTypes取值是'array'而非true。在 AJV 中coerceTypes除true/false外还接受'array',后者额外支持把单个值包装为数组——例如 query 中的"c"会被转成["c"]去匹配数组 schema。这与注释里 “string"50"→ number,"c"→["c"]” 的描述一致,且注释仍明确指出ajvQuery是query 参数校验专用。- 当前
ajv实例的coerceTypes为true。若你参照的文档/变更记录描述的“body 严格、零强转”行为为准,请注意实际取值可能随版本演进,编写依赖该校验行为的代码时,应以你所检出的 Ajv.ts 实际内容为准。两个实例都保留coerceTypes之外的三个选项:allowUnionTypes: true(允许联合类型 schema)、code.source: true(编译输出源码便于调试与 codegen 分析)、discriminator: true(启用oneOf按discriminator字段快速分派)。
2.3 共享的自定义 formats 与关键字
在 Ajv.ts 中,两个实例同步注册了以下扩展:
addFormats(ajv)/addFormats(ajvQuery):来自ajv-formats的标准 formats(日期、时间、URI、email、uuid 等);- 自定义 format
basic_email:/^[^@]+@[^@]+$/,仅做“存在 @ 且 @ 前后非空”的弱校验; - 自定义 format
rfc_email:更贴近 RFC 语法结构的正则; - 自定义关键字
isNotEmpty:作用于type: 'string'的字段,要求值非空且trim()后仍非空,用于杜绝“全空白字符串”这类数据。
当你在 schema 中写format: 'basic_email'、format: 'rfc_email'或isNotEmpty: true时,正是因为这两个实例都注册过它们,.compile()才能成功。
2.4 同文件还编译了统一的“错误响应”schema
值得注意:Ajv.ts 不止导出两个实例,还用ajv.compile()编译了一系列标准错误响应的 validator 并具名导出,包括:
validateBadRequestErrorResponse(schema 含success: false、可选errorType/details等,且additionalProperties: false);validateUnauthorizedErrorResponse、validateForbiddenErrorResponse;validateNotFoundErrorResponse、validateInternalErrorResponse。
这些 validator 供测试代码或中间件复用,用来断言错误响应的形状符合约定(如统一success: false)。这印证了“响应也应遵守 schema”的项目约定——其背后的强制机制见 第 6 节。
3. 为什么 Query 必须“宽松”,而 Body 应当“严格”
3.1 Query 参数在 HTTP 层永远是字符串
HTTP 协议本身不携带类型信息。URL 中?之后的部分到达服务端时,每个取值都是字符串:
?count=25→ 服务端收到count = "25"(string);?open=true→ 服务端收到open = "true"(string)。
因此,如果 schema 声明count是number、open是boolean,而使用的校验器不做强转,那么:
"25"不是number→ 校验失败,报 “must be number”,HTTP 层表现为invalid-params/error-invalid-params;"true"不是boolean→ 同样校验失败。
query 场景的解法就是使用coerceTypes生效的实例(即ajvQuery),让字符串在被校验的同时转换为 schema 期望的类型,转换后的值会作为后续 handler 拿到的参数。
3.2 Body 是 JSON,解析后已自带类型
对于POST/PUT/PATCH等请求,客户端发送的是 JSON body。服务端JSON.parse之后,数字就是number、布尔就是boolean,类型信息已经天然正确。此时再允许强转反而有害:
- 客户端发送
{ "count": "10" }(字符串)时,若实例开了强转,会“代为修正”并放行,掩盖调用方 bug; - 期望 string 却收到 number(如
{ "name": 123 })时,强转会把123变成"123"放行,与 API 契约相悖。
所以 body 场景的标准做法是使用不(默认)强转的ajv实例,由客户端负责在 JSON 中给出正确类型。变更记录也明确指出,若 API 调用方长期在 body 里传字符串数字/布尔,升级到严格校验后这类请求将被400拒绝,属于有意的破坏性变更。
4. 选型决策:何时用ajvQuery.compile、何时用ajv.compile
4.1 判断规则速查
| 数据来源 | 使用实例 | 理由 |
|---|---|---|
Query string(GET、?参数) | ajvQuery | query 值都是字符串,coerceTypes负责按 schema 期望把字符串转成number/boolean(乃至数组场景)。 |
| Body(POST/PUT/PATCH 的 JSON) | ajv | JSON 解析后类型已确定,做严格校验、不静默篡改值。 |
| 响应体(服务端返回的 JSON) | ajv | 响应同样是内存中的结构化 JSON(非字符串来源),默认不做强转,保证“声明即所返”。 |
4.2 适用ajvQuery.compile的典型场景
只要 validator 面向query 参数(GET 路由,或任何只从 query string 取参的方法),且 schema 中包含来自 URL 的number/integer/boolean类型属性,就应使用ajvQuery:
- 分页:
count、offset(数值); - 标志位:
open、readThreads(布尔); - 其他所有由客户端放在 query string 中的数值/布尔参数。
原文档给出的代表模式如下:
// GET /v1/livechat/rooms?count=25&offset=0 export const isGETLivechatRoomsParams = ajvQuery.compile<GETLivechatRoomsParams>(GETLivechatRoomsParamsSchema);在仓库源码中可以找到大量同构的真实实现。例如用户列表接口的 query 校验器就同时覆盖了分页数字与字符串字段:
// packages/rest-typings/src/v1/users/UsersListParamsGET.ts import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; import { ajvQuery } from '../Ajv'; export type UsersListParamsGET = PaginatedRequest<{ fields?: string; query?: string; email?: string; }>; const UsersListParamsGetSchema = { type: 'object', properties: { fields: { type: 'string', nullable: true }, query: { type: 'string', nullable: true }, count: { type: 'number', nullable: true }, offset: { type: 'number', nullable: true }, sort: { type: 'string', nullable: true }, email: { type: 'string', minLength: 1, nullable: true }, }, additionalProperties: false, }; export const isUsersListParamsGET = ajvQuery.compile<UsersListParamsGET>(UsersListParamsGetSchema);注意这里的细节:
count/offset声明为{ type: 'number', nullable: true },在ajvQuery下 URL 里?count=25&offset=0的字符串可被正确转换并通过校验;additionalProperties: false收紧 schema,防止传入未声明的多余 query 键;- 泛型参数
<UsersListParamsGET>让编译出的 validator 与 TypeScript 类型一一对应,类型即契约。
ajvQuery在仓库中的覆盖面很广:channels/groups/dm/teams 的历史、成员、文件、消息列表查询参数、videoConference 的VideoConfInfoProps/VideoConfListProps、autotranslate 支持语言列表、users.*ParamsGET系列、moderation报表分页等数十处 query validator 均从../Ajv导入它。搜索ajvQuery即可看到这些 GET 校验器的完整名单。
4.3 适用ajv.compile的典型场景
当 validator 面向请求 body(POST/PUT/PATCH 的 JSON)、响应体或内部结构时,使用ajv。原文档给出的代表模式:
// POST /v1/livechat/room/close — JSON body export const isPOSTLivechatRoomCloseParams = ajv.compile<POSTLivechatRoomCloseParams>(...);在源码中,响应 schema 校验器使用ajv的例子俯拾皆是,例如 apps/meteor/server/api/v1/ldap.ts 中为响应注册的:
200: ajv.compile<{ message: string; success: true }>(messageResponseSchema),apps/meteor/server/api/v1/misc.ts 也用ajv.compile<MeApiSuccessResponse>(meSuccessResponseSchema)校验/v1/me的响应。这些用例与“响应数据来自内存对象而非字符串来源”的判断一致。
5. Router 的运行时校验调用链:validator 在哪里被真正执行
定义好 validator 后,它们如何生效?关键在于路由定义的三个可选字段:options.query、options.body与options.response[statusCode]。真正执行它们的,是 Router.ts(@rocket.chat/http-router包)。
5.1 query 校验失败 →error-invalid-params
在请求进入 handler 之前,Router 先对 query params 执行options.query校验:
// packages/http-router/src/Router.ts#L210-L228(要点摘录) const validatorFn = options.query; if (typeof options.query === 'function' && !validatorFn(queryParams)) { // ... return c.json( { success: false, errorType: 'error-invalid-params', error: validatorFn.errors?.map((error) => error.message).join('\n '), }, 400, ); }失败时返回 HTTP400,errorType为error-invalid-params,并把 AJV 的每个错误 message 拼接在error字段中返回给调用方。
5.2 body 校验失败 →invalid-params
紧接着是对请求体的校验(body 由 Router 预先解析成bodyParams):
// packages/http-router/src/Router.ts#L234-L253(要点摘录) if (options.body) { const validatorFn = options.body; if (typeof options.body === 'function' && !validatorFn(bodyParams)) { // ... return c.json( { success: false, errorType: 'invalid-params', error: validatorFn.errors?.map((error) => error.message).join('\n '), }, 400, ); } }注意两处errorType的命名差异:query 失败是error-invalid-params,body 失败是invalid-params。排查 API 报错时,可用它们区分是 URL 参数问题还是请求体问题。
5.3 校验失败前,Router 都会先记录结构化日志
上述两段代码在返回400前都会调用logger.warn,日志字段包含method、path、error(AJV 错误消息集合)以及具体的bodyParams/queryParams快照。因此服务器日志中出现 “Query parameters validation failed - route spec does not match request payload” 或 “Request body validation failed ...” 时,基本可以确定是请求方的参数与路由 schema 不匹配。
5.4 共享 schema 的注册:core-typings 组件进入两个实例
Rocket.Chat 把大量跨端点复用的 schema 收在@rocket.chat/core-typings的schemas.components.schemas中。meteor 服务端启动时通过 apps/meteor/server/api/validation/ajv.ts 把它们同时注册进ajv与ajvQuery,从而让各端点的 schema 可以通过$ref: '#/components/schemas/xxx'引用这些公共组件:
import { schemas } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; const components = schemas.components?.schemas; if (components) { for (const key in components) { if (Object.prototype.hasOwnProperty.call(components, key)) { const uri = `#/components/schemas/${key}`; ajv.addSchema(components[key], uri); ajvQuery.addSchema(components[key], uri); } } }该文件还在注册前对若干歧义 schema 做了“加固”,以确保oneOf/discriminator 在严格校验下行为正确(详见 第 6 节)。这解释了为何双实例会同步拥有MessageAttachment等复杂组件的解析能力。
6. 响应校验、nullable与oneOf/discriminator 的注意事项
6.1 测试模式下的响应校验
响应 schema 同样通过ajv编译(coerceTypes语义上不改变值)。但在测试模式下 Router 会强制校验每个端点实际返回的响应是否匹配其声明的 schema,以提前暴露“实现与契约不一致”。
触发条件是环境变量NODE_ENV === 'test'或process.env.TEST_MODE(见 Router.ts)。此时 Router 会按状态码取出声明的校验器,对响应 body 校验:
// packages/http-router/src/Router.ts#L263-L296(要点摘录) if (process.env.NODE_ENV === 'test' || process.env.TEST_MODE) { const responseValidatorFn = options?.response?.[statusCode]; if (!responseValidatorFn && options.typed) { throw new Error(`Missing response validator for endpoint ${req.method} - ${req.url} with status code ${statusCode}`); } if (responseValidatorFn && !responseValidatorFn(coerceDatesToStrings(body))) { // ... return c.json( { success: false, body, errorType: 'error-invalid-body', error: `Invalid response for endpoint ${req.method} - ${req.url}. Error: ${errorMessage}`, }, 400, ); } }三个关键事实:
- 若路由声明了
typed: true却没有提供某个状态码的响应 validator,Router 会直接抛错 “Missing response validator for endpoint ...”——强制每个端点都必须把响应声明清楚; - 校验前会对响应 body 执行
coerceDatesToStrings(body)(把 Date 等内部对象转换为可 JSON 序列化的字符串形态),避免序列化差异误伤校验; - 校验失败时,Router 不是返回原始 200 响应,而是返回400,
errorType: 'error-invalid-body',错误消息会带instancePath与 AJV params 明细,便于定位是哪个字段不匹配。
6.2null值必须显式声明nullable: true
在开启强转的旧行为下,null可能被静默转换(例如 string 字段的null被变成"")。而在严格校验(coerceTypes关闭或语义上不改变 null)下,任何可能为null的字段都必须在 schema 中声明nullable: true,否则响应校验器会拒绝该响应。
一个典型例子是视频会议用户对象中的avatarETag,它可能为null。声明如下:
// WRONG —— avatarETag 为 null 时校验失败 { type: 'string' } // CORRECT { type: 'string', nullable: true }对应在源码里,大量 query/响应 schema(例如 UsersListParamsGET.ts)都为可空字段统一写了nullable: true,就是这个约定的直接体现。
6.3oneOf与 discriminator schema 的严格化
不使用强转后,依赖oneOf且带严格 enum 判别字段(例如type: { enum: ['direct'] })的 schema 会变得苛刻:
- 若实际数据的判别字段取值没有落在任何一个分支的 enum 中,
oneOf将整体失败; - 只要某一分支因多余字段或宽松结构误匹配,就可能违反
oneOf“恰好命中一个分支”的规则。
应对策略有两条:要么确保 schema 覆盖全部可能的判别值;要么在运行时不需要完整类型校验的位置放宽该项,例如把条目 schema 退化为{ type: 'object' }。
仓库为此还有一个值得借鉴的实践:apps/meteor/server/api/validation/ajv.ts 在把公共组件注册进实例前,会对歧义分支做additionalProperties: false加固。例如:
- 给
MessageAttachmentDefault补上additionalProperties: false,避免“兜底附件分支”匹配一切对象,破坏oneOf判别; - 遍历
components,凡是判别字段唯一值为'file'且不含image_url/video_url/audio_url的“纯文件”分支,都收紧additionalProperties,使图片/音视频附件只能命中各自专用分支。
这段代码的背景注释解释得很清楚:若不加约束,一个图片附件会同时满足“图片专用分支”和“纯文件兜底分支”,从而违反oneOf的“恰好一个”规则,导致任何带文件/引用文件附件的消息在响应校验时报error-invalid-body。这说明响应 schema 的歧义(ambiguity)在无强转 + 严格 oneOf 下会从“可容忍”变成“必然报错”,需要在 schema 层面消除。
7. 常见错误与排查速查
7.1 三类高频错误
用
ajv校验 query 参数,而 schema 期望number/boolean:- 客户端发
?count=25; - 校验器拿到字符串
"25"但期望number; - 结果:HTTP 400,
errorType: 'error-invalid-params',错误形如 “must be number”。 - 修复:改用
ajvQuery.compile(...),或把 schema 字段声明为可同时接受字符串与数字的形态。
- 客户端发
用
ajvQuery校验 body:- 多数情况下能通过,因为强转掩盖了类型错误(例如
{ count: "10" }被放行); - 但这让客户端以为字符串数字合法,属于“校验形同虚设”。
- 修复:body 一律走
ajv,要求调用方在 JSON 中给出正确类型。
- 多数情况下能通过,因为强转掩盖了类型错误(例如
响应 schema 未处理
null字段(测试模式):- 严格校验下
null不再被强转为""或0; - 可为空的字段必须写
nullable: true; - 症状:端点逻辑明明成功,测试却收到
400且errorType: 'error-invalid-body',服务端日志提示某字段 “must be string”(实际为 null)。
- 严格校验下
7.2 错误码快速定位
| 场景 | HTTP 状态 | errorType | 出处 |
|---|---|---|---|
| query 参数校验失败 | 400 | error-invalid-params | Router.ts |
| body 校验失败 | 400 | invalid-params | Router.ts |
| 测试模式下响应校验失败 | 400 | error-invalid-body | Router.ts |
typed路由缺失响应 validator | — | (抛错) | Router.ts |
8. 实操指南:为端点编写并使用正确的 validator
结合以上分析,一个符合 Rocket.Chat 当前约定的完整流程是:
- 声明 schema:在
packages/rest-typings/src/v1/下对应的模块文件中,用 JSON Schema 描述参数。query 里的数字/布尔字段、可为空字段分别按{ type: 'number', nullable: true }、{ type: 'string', nullable: true }声明;无用的键用additionalProperties: false拒绝。 - 选择实例并编译:
- GET / query →
import { ajvQuery } from '../Ajv',再export const isXxxParamsGET = ajvQuery.compile<XxxParamsGET>(schema); - POST / body →
import { ajv } from '../Ajv',再export const isPOSTXxxParams = ajv.compile<XxxParams>(schema); - 命名习惯可从源码中归纳为
is{Method}{Endpoint}{后缀}形式,例如isUsersListParamsGET。
- GET / query →
- 挂载到路由:在端点的路由定义中把 validator 放进对应字段——
options.query、options.body、options.response[statusCode](后者由ajv.compile产出的响应校验器填充;参照 ldap.ts 中200: ajv.compile(...)的写法)。 - 需要复用核心组件时:确保使用的组件已存在于
@rocket.chat/core-typings的schemas.components.schemas;meteor 端启动逻辑会把它们自动注册到两个实例,schema 内用#/components/schemas/...引用即可。 - 用测试验证:在测试模式下运行 API 测试,Router 会替你校验每个响应;若出现
error-invalid-body,根据error消息里的instancePath定位是缺nullable: true,还是oneOf分支歧义(可参考 validation/ajv.ts 的additionalProperties: false加固思路)。
若需要为公共 schema 新增“不允许空白字符串”“邮箱格式”等约束,可直接复用两个实例上已注册的isNotEmpty、basic_email、rfc_email(见 Ajv.ts),无需重复实现正则。
9. 小结
ajv与ajvQuery的区分本质上是“HTTP 数据来源决定校验策略”:query string 天生是字符串,需要一个允许类型强转的实例(ajvQuery)去适配 schema;JSON body 与响应对象已是结构化类型,需要默认不篡改值的实例(ajv)做严格把关。二者共享同一份 options、formats、keywords 与公共组件 schema,编译出的 validator 在 Router.ts 中分别以error-invalid-params、invalid-params与(测试模式下的)error-invalid-body呈现失败结果。理解这条从 schema →.compile()→ 路由挂载 → 运行时校验 → 错误码的完整链路,是可靠编写和调试 Rocket.Chat REST API 类型校验的基础。
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考