参数校验

VextJS 集成 schema-dsl,提供声明式参数校验。在路由 options.validate 中用简洁的 DSL 字符串描述校验规则,框架自动完成校验、类型转换,并同步生成 OpenAPI 文档。

基本用法

在路由的三段式定义中,通过 validate 字段声明校验规则:

import { defineRoutes } from 'vextjs';

export default defineRoutes((app) => {
  app.post('/users', {
    validate: {
      body: {
        name: 'string:1-50!',    // 必填字符串,长度 1-50
        email: 'email!',          // 必填,邮箱格式
        age: 'number?',           // 可选数字
        role: 'admin|user',       // 枚举值
      },
    },
    docs: { summary: '创建用户' },
  }, async (req, res) => {
    // req.body 已通过校验 + 类型转换
    const data = req.valid('body');
    const user = await app.services.user.create(data);
    res.json(user, 201);
  });
});

校验通过后,通过 req.valid(location) 获取经过类型转换的数据。校验失败时框架自动返回 422 错误响应,无需手动处理。

校验位置

validate 支持四个位置,对应请求的不同数据来源:

位置数据来源说明
paramreq.params路径动态参数(如 /:id
queryreq.queryURL 查询参数(如 ?page=1
headerreq.headers请求头
bodyreq.body请求体(JSON / URL-encoded)

校验按 paramqueryheaderbody 的顺序执行,任一位置校验失败会立即返回错误。

app.put('/users/:id', {
  validate: {
    param: {
      id: 'string!',
    },
    query: {
      fields: 'string?',       // 可选,指定返回字段
    },
    header: {
      'x-api-version': 'string?',
    },
    body: {
      name: 'string:1-50?',
      email: 'email?',
    },
  },
}, async (req, res) => {
  const { id } = req.valid('param');
  const body = req.valid('body');
  const user = await app.services.user.update(id, body);
  res.json(user);
});
注意

validate 中使用单数 param(与路径参数概念对应),但底层数据源是 req.params(复数)。框架内部已做正确映射,你无需关心。

DSL 语法详解

schema-dsl 使用简洁的字符串表达式描述数据类型和约束。

基本类型

DSL 表达式含义示例值
'string'字符串"hello"
'number'数字423.14
'boolean'布尔值truefalse
'email'邮箱格式"user@example.com"
'url'URL 格式"https://example.com"
'date'日期字符串"2026-01-15"

必填与可选

在类型表达式末尾添加 !? 标记:

后缀含义示例
!必填(required)'string!' — 必填字符串
?可选(optional)'string?' — 可选字符串
无后缀可选(默认)'string' — 等同于 'string?'
validate: {
  body: {
    name: 'string!',     // 必填
    nickname: 'string?', // 可选
    bio: 'string',       // 可选(等同于 'string?')
  },
}

范围约束

使用 :min-max 语法指定范围:

字符串长度

'string:1-50'     // 长度 1 到 50
'string:1-50!'    // 必填,长度 1 到 50
'string:5-'       // 最小长度 5,无上限
'string:-100'     // 最大长度 100

数字范围

'number:1-100'    // 值范围 1 到 100
'number:0-'       // 最小值 0(非负数)
'number:1-'       // 最小值 1(正整数/正数)
'number:-999'     // 最大值 999
'number:18-120!'  // 必填,范围 18 到 120

枚举值

使用 | 分隔枚举选项:

'admin|user|guest'         // 枚举:admin / user / guest
'draft|published|archived' // 枚举:draft / published / archived
'male|female|other'        // 枚举:male / female / other

枚举值始终为字符串类型。在 OpenAPI 文档中映射为 enum

组合示例

validate: {
  body: {
    // 基本类型 + 必填/可选
    username: 'string:3-30!',      // 必填字符串,长度 3-30
    password: 'string:8-128!',     // 必填字符串,长度 8-128
    email: 'email!',               // 必填邮箱
    website: 'url?',               // 可选 URL
    age: 'number:0-150?',          // 可选数字,范围 0-150
    score: 'number:0-100',         // 可选数字,范围 0-100
    active: 'boolean!',            // 必填布尔值
    role: 'admin|editor|viewer',   // 枚举
    birthday: 'date?',             // 可选日期
  },
}

类型转换

schema-dsl 在校验时自动执行类型转换,尤其对 queryparam 数据非常有用(它们原始值始终是字符串):

声明类型原始值转换后
'number'"42"42
'number'"3.14"3.14
'boolean'"true"true
'boolean'"false"false
'boolean'"1"true
'boolean'"0"false
app.get('/search', {
  validate: {
    query: {
      page: 'number:1-',      // ?page=3 → number 3(非字符串 "3")
      limit: 'number:1-100',   // ?limit=20 → number 20
      active: 'boolean',       // ?active=true → boolean true
    },
  },
}, async (req, res) => {
  const { page, limit, active } = req.valid('query');
  // page: number, limit: number, active: boolean — 已自动转换
  res.json({ page, limit, active });
});

获取校验后数据

req.valid(location)

使用 req.valid() 获取经过校验和类型转换后的数据。必须在 validate 中配置了对应位置后才能调用。

app.post('/orders', {
  validate: {
    body: {
      productId: 'string!',
      quantity: 'number:1-99!',
    },
    query: {
      coupon: 'string?',
    },
  },
}, async (req, res) => {
  const body = req.valid('body');     // { productId: string, quantity: number }
  const query = req.valid('query');   // { coupon?: string }

  const order = await app.services.order.create(body, query.coupon);
  res.json(order, 201);
});

边界行为

注意事项

req.valid(location) 有以下边界行为需要了解:

  1. 未配置 validate 时调用:如果路由未配置 validate 字段,调用 req.valid('body') 将返回 undefined。框架不会抛出错误,但你将无法获取经过校验和类型转换的数据。

  2. location 未在 validate 中声明:如果 validate 中只声明了 body,但调用了 req.valid('query'),同样返回 undefined。只有在 validate 中明确声明过的位置才会有校验后的数据。

  3. 校验失败时不会到达 handler:当校验失败时,框架会在 handler 执行之前自动返回 422 错误响应,因此在 handler 内部调用 req.valid() 时数据一定是已通过校验的。

// ⚠️ 边界情况示例
app.get('/items', {
  validate: {
    query: { page: 'number:1-' },
    // 未声明 body
  },
}, async (req, res) => {
  const query = req.valid('query');   // ✅ { page: number } — 已校验
  const body = req.valid('body');     // ⚠️ undefined — 未在 validate 中声明
  const param = req.valid('param');   // ⚠️ undefined — 未在 validate 中声明
  res.json({ query });
});

// ⚠️ 未配置 validate 的路由
app.get('/health', async (req, res) => {
  const body = req.valid('body');     // ⚠️ undefined — 路由未配置 validate
  res.json({ status: 'ok' });
});

最佳实践:始终确保 req.valid(location)locationvalidate 中声明的位置一致。

泛型类型提示

可以使用泛型获得更精确的 IDE 类型提示:

interface CreateUserBody {
  name: string;
  email: string;
  age?: number;
}

app.post('/users', {
  validate: {
    body: {
      name: 'string:1-50!',
      email: 'email!',
      age: 'number:0-150?',
    },
  },
}, async (req, res) => {
  const data = req.valid<CreateUserBody>('body');
  // data.name — IDE 知道是 string
  // data.email — IDE 知道是 string
  // data.age — IDE 知道是 number | undefined
  res.json(await app.services.user.create(data));
});

校验错误响应

当校验失败时,框架自动返回 422 状态码的结构化错误响应:

{
  "code": 422,
  "message": "Validation failed",
  "errors": [
    {
      "field": "email",
      "message": "must be a valid email address"
    },
    {
      "field": "name",
      "message": "length must be between 1 and 50"
    }
  ],
  "requestId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
  • code:HTTP 状态码 422(Unprocessable Entity)
  • message:固定为 "Validation failed"
  • errors:字段级错误数组,包含 field(字段名)和 message(错误描述)
  • requestId:当前请求的唯一标识

校验错误由框架全局错误处理器统一处理,你不需要在路由中手动 try-catch。

与 OpenAPI 文档的联动

validate 中的 DSL 规则会自动映射到 OpenAPI 文档的 parametersrequestBody 定义。无需额外配置,校验规则即是文档规则:

app.get('/users', {
  validate: {
    query: {
      page: 'number:1-',
      limit: 'number:1-100',
      status: 'active|inactive|banned',
    },
  },
  docs: { summary: '获取用户列表' },
}, handler);

上面的路由会在 OpenAPI 文档中自动生成:

  • page — query parameter, type: integer, minimum: 1
  • limit — query parameter, type: integer, minimum: 1, maximum: 100
  • status — query parameter, type: string, enum: ["active", "inactive", "banned"]

访问 /docs 即可在 Scalar 文档中查看自动生成的参数文档和内置的 "Try it out" 功能。

高级用法

多位置组合校验

同一路由可以同时校验多个位置:

app.put('/users/:id/avatar', {
  validate: {
    param: { id: 'string!' },
    header: { 'content-type': 'string!' },
    query: { size: 'number:32-512?' },
    body: { url: 'url!', alt: 'string:0-200?' },
  },
}, async (req, res) => {
  const { id } = req.valid('param');
  const { url, alt } = req.valid('body');
  const { size } = req.valid('query');

  await app.services.user.updateAvatar(id, { url, alt, size });
  res.json({ success: true });
});

与路由级中间件配合

校验中间件在路由级中间件之后、handler 之前执行。这意味着:

请求 → [全局中间件] → [路由级中间件: auth, check-role] → [validate 校验] → [handler]

认证检查先于参数校验执行,未认证的请求不会触发校验逻辑:

app.post('/admin/users', {
  middlewares: ['auth', { name: 'check-role', options: { roles: ['admin'] } }],
  validate: {
    body: {
      name: 'string:1-50!',
      email: 'email!',
      role: 'admin|editor|viewer!',
    },
  },
}, handler);

路由覆盖限流规则

除了参数校验,options 还支持路由级配置覆盖(override),可以为特定路由调整限流、超时等设置:

app.post('/login', {
  validate: {
    body: {
      email: 'email!',
      password: 'string:8-128!',
    },
  },
  override: {
    rateLimit: { max: 5, window: 60000 },  // 每分钟最多 5 次
  },
}, handler);

app.get('/public/health', {
  override: {
    rateLimit: false,  // 健康检查不限流
  },
}, handler);

替换校验引擎

VextJS 默认使用 schema-dsl 作为校验引擎。如果你更喜欢 Zod、Yup 等第三方校验库,可以通过插件替换内置校验引擎。

使用 Zod 示例

// src/plugins/zod-validator.ts
import { definePlugin } from 'vextjs';
import type { VextValidator } from 'vextjs';
import { z } from 'zod';

export default definePlugin({
  name: 'zod-validator',

  setup(app) {
    const zodValidator: VextValidator = {
      validate(schema, data) {
        // 当 schema 是 Zod schema 时使用 Zod 校验
        if (schema instanceof z.ZodType) {
          const result = schema.safeParse(data);
          if (!result.success) {
            return {
              valid: false,
              errors: result.error.issues.map((issue) => ({
                field: issue.path.join('.'),
                message: issue.message,
              })),
            };
          }
          return { valid: true, data: result.data };
        }
        // 否则退回默认行为
        return { valid: true, data };
      },
      toJSONSchema(schema) {
        // 转换为 JSON Schema 供 OpenAPI 使用
        // 可使用 zod-to-json-schema 库
        return {};
      },
    };

    app.setValidator(zodValidator);
    app.logger.info('Zod validator plugin activated');
  },
});

替换后,路由中的 validate 可以传入 Zod schema 对象而非 DSL 字符串。

常见模式

分页查询

app.get('/posts', {
  validate: {
    query: {
      page: 'number:1-',
      limit: 'number:1-100',
      sort: 'createdAt|updatedAt|title',
      order: 'asc|desc',
    },
  },
}, async (req, res) => {
  const { page = 1, limit = 20, sort = 'createdAt', order = 'desc' } = req.valid('query');
  const posts = await app.services.post.findAll({ page, limit, sort, order });
  res.json(posts);
});

搜索过滤

app.get('/products', {
  validate: {
    query: {
      keyword: 'string?',
      category: 'string?',
      minPrice: 'number:0-?',
      maxPrice: 'number:0-?',
      inStock: 'boolean?',
    },
  },
}, async (req, res) => {
  const filters = req.valid('query');
  const products = await app.services.product.search(filters);
  res.json(products);
});

用户注册

app.post('/auth/register', {
  validate: {
    body: {
      username: 'string:3-30!',
      email: 'email!',
      password: 'string:8-128!',
      confirmPassword: 'string:8-128!',
    },
  },
  override: {
    rateLimit: { max: 3, window: 60000 },
  },
}, async (req, res) => {
  const data = req.valid('body');

  if (data.password !== data.confirmPassword) {
    app.throw(400, '两次密码不一致');
  }

  const user = await app.services.auth.register(data);
  res.json(user, 201);
});

文件路径参数

// src/routes/files/[id].ts
app.get('/', {
  validate: {
    param: { id: 'string!' },
    query: { download: 'boolean?' },
  },
}, async (req, res) => {
  const { id } = req.valid('param');
  const { download } = req.valid('query');

  const file = await app.services.file.findById(id);
  if (!file) app.throw(404, 'file.not_found');

  if (download) {
    res.download(file.stream, file.name, file.contentType);
  } else {
    res.json(file.metadata);
  }
});

最佳实践

1. 始终使用 req.valid() 而非 req.body

配置了 validate 的路由,应使用 req.valid('body') 而非直接访问 req.body

// ✅ 正确 — 使用校验后的数据
const data = req.valid('body');

// ❌ 避免 — 跳过了类型转换
const data = req.body;

req.valid() 返回的数据经过了类型转换(如 query 中的 "42"42),直接访问 req.body / req.query 则是原始数据。

2. 合理使用必填标记

对于 queryheader 位置,通常使用可选(?);对于 body 中的核心字段,使用必填(!):

validate: {
  query: {
    page: 'number:1-',       // 可选(分页有默认值)
    keyword: 'string?',      // 可选(搜索关键字)
  },
  body: {
    name: 'string:1-50!',    // 必填(创建资源必须提供)
    email: 'email!',          // 必填
    bio: 'string:0-500?',    // 可选
  },
}

3. 校验规则即文档

由于校验规则自动映射到 OpenAPI 文档,写好 validate 就相当于写好了接口文档。尽量精确地描述约束条件:

// ✅ 精确约束 — 文档和校验都很清晰
validate: {
  body: {
    username: 'string:3-30!',     // 3-30 字符,必填
    age: 'number:0-150?',         // 0-150,可选
    role: 'admin|editor|viewer!', // 明确枚举
  },
}

// ❌ 宽泛约束 — 文档信息不足
validate: {
  body: {
    username: 'string!',          // 没有长度约束
    age: 'number?',               // 没有范围约束
    role: 'string!',              // 应该用枚举
  },
}

4. 为 Handler 中的自定义校验使用 app.throw()

DSL 语法无法覆盖所有校验场景(如跨字段校验、数据库唯一性检查)。对于这些场景,在 handler 或 service 中使用 app.throw() 手动抛出:

app.post('/users', {
  validate: {
    body: { email: 'email!', password: 'string:8-128!' },
  },
}, async (req, res) => {
  const data = req.valid('body');

  // 数据库唯一性检查 — DSL 无法覆盖
  const existing = await app.services.user.findByEmail(data.email);
  if (existing) {
    app.throw(409, '邮箱已注册', 10001);
  }

  const user = await app.services.user.create(data);
  res.json(user, 201);
});

下一步

  • 了解 配置 中校验相关的全局配置
  • 查看 OpenAPI 文档 如何与校验规则联动
  • 学习 路由 中三段式的完整用法
  • 探索 插件 如何替换校验引擎