Fastify

验证和序列化

Fastify 使用基于 schema 的途径,从本质上将 schema 编译成了高性能的函数,来实现路由的验证与输出的序列化。我们推荐使用 JSON Schema,虽然这并非必要。

⚠ 安全须知

应当将 schema 的定义写入代码。 因为不管是验证还是序列化,都会使用 new Function() 来动态生成代码并执行。 所以,用户提供的 schema 是不安全的。 更多内容,请看 Ajvfast-json-stringify

核心观念

验证与序列化的任务分别由两个可定制的工具完成:

这些工具相互独立,但共享通过 .addSchema(schema) 方法添加到 Fastify 实例上的 JSON schema。

添加共用 schema (shared schema)

得益于 addSchema API,你能向 Fastify 实例添加多个 schema,并在程序的不同部分复用它们。 像往常一样,该 API 是封装好的。

共用 schema 可以通过 JSON Schema 的 $ref 关键字复用。 以下是引用方法的 总结

  • myField: { $ref: '#foo'} 将在当前 schema 内搜索 $id: '#foo' 字段。
  • myField: { $ref: '#/definitions/foo'} 将在当前 schema 内搜索 definitions.foo 字段。
  • myField: { $ref: 'http://url.com/sh.json#'} 会搜索含 $id: 'http://url.com/sh.json' 的共用 schema。
  • myField: { $ref: 'http://url.com/sh.json#/definitions/foo'} 会搜索含 $id: 'http://url.com/sh.json' 的共用 schema,并使用其 definitions.foo 字段。
  • myField: { $ref: 'http://url.com/sh.json#foo'} 会搜索含 $id: 'http://url.com/sh.json' 的共用 schema,并使用其内部带 $id: '#foo' 的对象。

简单用法:

  1. fastify.addSchema({
  2. $id: 'http://example.com/',
  3. type: 'object',
  4. properties: {
  5. hello: { type: 'string' }
  6. }
  7. })
  8. fastify.post('/', {
  9. handler () {},
  10. schema: {
  11. body: {
  12. type: 'array',
  13. items: { $ref: 'http://example.com#/properties/hello' }
  14. }
  15. }
  16. })

$ref 作为根引用 (root reference):

  1. fastify.addSchema({
  2. $id: 'commonSchema',
  3. type: 'object',
  4. properties: {
  5. hello: { type: 'string' }
  6. }
  7. })
  8. fastify.post('/', {
  9. handler () {},
  10. schema: {
  11. body: { $ref: 'commonSchema#' },
  12. headers: { $ref: 'commonSchema#' }
  13. }
  14. })

获取共用 schema

当自定义验证器或序列化器的时候,Fastify 不再能控制它们,此时 .addSchema 方法失去了作用。 要获取添加到 Fastify 实例上的 schema,你可以使用 .getSchemas()

  1. fastify.addSchema({
  2. $id: 'schemaId',
  3. type: 'object',
  4. properties: {
  5. hello: { type: 'string' }
  6. }
  7. })
  8. const mySchemas = fastify.getSchemas()
  9. const mySchema = fastify.getSchema('schemaId')

getSchemas 方法也是封装好的,返回的是指定作用域中可用的共用 schema:

  1. fastify.addSchema({ $id: 'one', my: 'hello' })
  2. // 只返回 schema `one`
  3. fastify.get('/', (request, reply) => { reply.send(fastify.getSchemas()) })
  4. fastify.register((instance, opts, done) => {
  5. instance.addSchema({ $id: 'two', my: 'ciao' })
  6. // 会返回 schema `one` 与 `two`
  7. instance.get('/sub', (request, reply) => { reply.send(instance.getSchemas()) })
  8. instance.register((subinstance, opts, done) => {
  9. subinstance.addSchema({ $id: 'three', my: 'hola' })
  10. // 会返回 schema `one`、`two` 和 `three`
  11. subinstance.get('/deep', (request, reply) => { reply.send(subinstance.getSchemas()) })
  12. done()
  13. })
  14. done()
  15. })

验证

路由的验证是依赖 Ajv 6 实现的。这是一个高性能的 JSON schema 校验工具。验证输入十分简单,只需将字段加入路由的 schema 中即可!

支持的验证类型如下:

  • body:当请求方法为 POST、PUT 或 PATCH 时,验证 body。
  • querystringquery:验证 querystring。
  • params:验证路由参数。
  • headers:验证 header。

所有的验证都可以是一个完整的 JSON Schema 对象 (包括值为 objecttype 属性以及包含参数的 properties 对象),也可以是一个没有 typeproperties,而仅仅在顶层列明参数的简单变种 (见下文示例)。

ℹ 想要使用最新版 Ajv (Ajv 8) 的话,请查阅 schemaController 一节,里边描述了比自定义校验器更简单的方法。

示例:

  1. const bodyJsonSchema = {
  2. type: 'object',
  3. required: ['requiredKey'],
  4. properties: {
  5. someKey: { type: 'string' },
  6. someOtherKey: { type: 'number' },
  7. requiredKey: {
  8. type: 'array',
  9. maxItems: 3,
  10. items: { type: 'integer' }
  11. },
  12. nullableKey: { type: ['number', 'null'] }, // 或 { type: 'number', nullable: true }
  13. multipleTypesKey: { type: ['boolean', 'number'] },
  14. multipleRestrictedTypesKey: {
  15. oneOf: [
  16. { type: 'string', maxLength: 5 },
  17. { type: 'number', minimum: 10 }
  18. ]
  19. },
  20. enumKey: {
  21. type: 'string',
  22. enum: ['John', 'Foo']
  23. },
  24. notTypeKey: {
  25. not: { type: 'array' }
  26. }
  27. }
  28. }
  29. const queryStringJsonSchema = {
  30. type: 'object',
  31. properties: {
  32. name: { type: 'string' },
  33. excitement: { type: 'integer' }
  34. }
  35. }
  36. const paramsJsonSchema = {
  37. type: 'object',
  38. properties: {
  39. par1: { type: 'string' },
  40. par2: { type: 'number' }
  41. }
  42. }
  43. const headersJsonSchema = {
  44. type: 'object',
  45. properties: {
  46. 'x-foo': { type: 'string' }
  47. },
  48. required: ['x-foo']
  49. }
  50. const schema = {
  51. body: bodyJsonSchema,
  52. querystring: queryStringJsonSchema,
  53. params: paramsJsonSchema,
  54. headers: headersJsonSchema
  55. }
  56. fastify.post('/the/url', { schema }, handler)

请注意,为了通过校验,并在后续过程中使用正确类型的数据,Ajv 会尝试将数据隐式转换为 schema 中 type 属性指明的类型。

Fastify 提供给 Ajv 的默认配置并不支持隐式转换 querystring 中的数组参数。但是,Fastify 允许你通过设置 Ajv 实例的 customOptions 选项为 ‘array’,来将参数转换为数组。举例如下:

  1. const opts = {
  2. schema: {
  3. querystring: {
  4. type: 'object',
  5. properties: {
  6. ids: {
  7. type: 'array',
  8. default: []
  9. },
  10. },
  11. }
  12. }
  13. }
  14. fastify.get('/', opts, (request, reply) => {
  15. reply.send({ params: request.query })
  16. })
  17. fastify.listen(3000, (err) => {
  18. if (err) throw err
  19. })

默认情况下,该处的请求将返回 400

  1. curl -X GET "http://localhost:3000/?ids=1
  2. {"statusCode":400,"error":"Bad Request","message":"querystring/hello should be array"}

设置 coerceTypes 的值为 ‘array’ 将修复该问题:

  1. const ajv = new Ajv({
  2. removeAdditional: true,
  3. useDefaults: true,
  4. coerceTypes: 'array', // 看这里
  5. allErrors: true
  6. })
  7. fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
  8. return ajv.compile(schema)
  9. })
  1. curl -X GET "http://localhost:3000/?ids=1
  2. {"params":{"hello":["1"]}}

你还可以给每个参数类型 (body, query string, param, header) 都自定义 schema 校验器。

下面的例子改变了 ajv 的默认选项,禁用了 body 的强制类型转换。

  1. const schemaCompilers = {
  2. body: new Ajv({
  3. removeAdditional: false,
  4. coerceTypes: false,
  5. allErrors: true
  6. }),
  7. params: new Ajv({
  8. removeAdditional: false,
  9. coerceTypes: true,
  10. allErrors: true
  11. }),
  12. querystring: new Ajv({
  13. removeAdditional: false,
  14. coerceTypes: true,
  15. allErrors: true
  16. }),
  17. headers: new Ajv({
  18. removeAdditional: false,
  19. coerceTypes: true,
  20. allErrors: true
  21. })
  22. }
  23. server.setValidatorCompiler(req => {
  24. if (!req.httpPart) {
  25. throw new Error('Missing httpPart')
  26. }
  27. const compiler = schemaCompilers[req.httpPart]
  28. if (!compiler) {
  29. throw new Error(`Missing compiler for ${req.httpPart}`)
  30. }
  31. return compiler.compile(req.schema)
  32. })

更多信息请看这里

Ajv 插件

你可以给默认的 ajv 实例提供一组插件。这些插件必须兼容 Ajv 6

插件格式参见 ajv 选项

  1. const fastify = require('fastify')({
  2. ajv: {
  3. plugins: [
  4. require('ajv-merge-patch')
  5. ]
  6. }
  7. })
  8. fastify.post('/', {
  9. handler (req, reply) { reply.send({ ok: 1 }) },
  10. schema: {
  11. body: {
  12. $patch: {
  13. source: {
  14. type: 'object',
  15. properties: {
  16. q: {
  17. type: 'string'
  18. }
  19. }
  20. },
  21. with: [
  22. {
  23. op: 'add',
  24. path: '/properties/q',
  25. value: { type: 'number' }
  26. }
  27. ]
  28. }
  29. }
  30. }
  31. })
  32. fastify.post('/foo', {
  33. handler (req, reply) { reply.send({ ok: 1 }) },
  34. schema: {
  35. body: {
  36. $merge: {
  37. source: {
  38. type: 'object',
  39. properties: {
  40. q: {
  41. type: 'string'
  42. }
  43. }
  44. },
  45. with: {
  46. required: ['q']
  47. }
  48. }
  49. }
  50. }
  51. })

验证生成器

validatorCompiler 返回一个用于验证 body、URL、路由参数、header 以及 querystring 的函数。默认返回一个实现了 ajv 验证接口的函数。Fastify 内在地使用该函数以加速验证。

Fastify 使用的 ajv 基本配置如下:

  1. {
  2. removeAdditional: true, // 移除额外属性
  3. useDefaults: true, // 当属性或项目缺失时,使用 schema 中预先定义好的 default 的值代替
  4. coerceTypes: true, // 根据定义的 type 的值改变数据类型
  5. nullable: true // 支持 OpenAPI Specification 3.0 版本的 "nullable" 关键字
  6. }

上述配置可通过 ajv.customOptions 修改。

假如你想改变或增加额外的选项,你需要创建一个自定义的实例,并覆盖已存在的实例:

  1. const fastify = require('fastify')()
  2. const Ajv = require('ajv')
  3. const ajv = new Ajv({
  4. // fastify 使用的默认参数(如果需要)
  5. removeAdditional: true,
  6. useDefaults: true,
  7. coerceTypes: true,
  8. nullable: true,
  9. // 任意其他参数
  10. // ...
  11. })
  12. fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
  13. return ajv.compile(schema)
  14. })

注意: 如果你使用自定义校验工具的实例(即使是 Ajv),你应当向该实例而非 Fastify 添加 schema,因为在这种情况下,Fastify 默认的校验工具不再使用,而 addSchema 方法也不清楚你在使用什么工具进行校验。

使用其他验证工具

通过 setValidatorCompiler 函数,你可以轻松地将 ajv 替换为几乎任意的 Javascript 验证工具 (如 joiyup 等),或自定义它们。

  1. const Joi = require('@hapi/joi')
  2. fastify.post('/the/url', {
  3. schema: {
  4. body: Joi.object().keys({
  5. hello: Joi.string().required()
  6. }).required()
  7. },
  8. validatorCompiler: ({ schema, method, url, httpPart }) => {
  9. return data => schema.validate(data)
  10. }
  11. }, handler)
  1. const yup = require('yup')
  2. // 等同于前文 ajv 基本配置的 yup 的配置
  3. const yupOptions = {
  4. strict: false,
  5. abortEarly: false, // 返回所有错误(译注:为 true 时出现首个错误后即返回)
  6. stripUnknown: true, // 移除额外属性
  7. recursive: true
  8. }
  9. fastify.post('/the/url', {
  10. schema: {
  11. body: yup.object({
  12. age: yup.number().integer().required(),
  13. sub: yup.object().shape({
  14. name: yup.string().required()
  15. }).required()
  16. })
  17. },
  18. validatorCompiler: ({ schema, method, url, httpPart }) => {
  19. return function (data) {
  20. // 当设置 strict = false 时, yup 的 `validateSync` 函数在验证成功后会返回经过转换的值,而失败时则会抛错。
  21. try {
  22. const result = schema.validateSync(data, yupOptions)
  23. return { value: result }
  24. } catch (e) {
  25. return { error: e }
  26. }
  27. }
  28. }
  29. }, handler)
其他验证工具的验证信息

Fastify 的错误验证与其默认的验证引擎 ajv 紧密结合,错误最终会经由 schemaErrorsText 函数转化为便于阅读的信息。然而,也正是由于 schemaErrorsTextajv 的强关联性,当你使用其他校验工具时,可能会出现奇怪或不完整的错误信息。

要规避以上问题,主要有两个途径:

  1. 确保自定义的 schemaCompiler 返回的错误结构与 ajv 的一致 (当然,由于各引擎的差异,这是件困难的活儿)。
  2. 使用自定义的 errorHandler 拦截并格式化验证错误。

Fastify 给所有的验证错误添加了两个属性,来帮助你自定义 errorHandler

  • validation:来自 schemaCompiler 函数的验证函数所返回的对象上的 error 属性的内容。
  • validationContext:验证错误的上下文 (body、params、query、headers)。

以下是一个自定义 errorHandler 来处理验证错误的例子:

  1. const errorHandler = (error, request, reply) => {
  2. const statusCode = error.statusCode
  3. let response
  4. const { validation, validationContext } = error
  5. // 检验是否发生了验证错误
  6. if (validation) {
  7. response = {
  8. // validationContext 的值可能是 'body'、'params'、'headers' 或 'query'
  9. message: `A validation error occured when validating the ${validationContext}...`,
  10. // 验证工具返回的结果
  11. errors: validation
  12. }
  13. } else {
  14. response = {
  15. message: 'An error occurred...'
  16. }
  17. }
  18. // 其余代码。例如,记录错误日志。
  19. // ...
  20. reply.status(statusCode).send(response)
  21. }

序列化

通常,你会通过 JSON 格式将数据发送至客户端。鉴于此,Fastify 提供了一个强大的工具——fast-json-stringify 来帮助你。当你在路由选项中提供了输出的 schema 时,它能派上用场。 我们推荐你编写一个输出的 schema,因为这能让应用的吞吐量提升 100-400% (根据 payload 的不同而有所变化),也能防止敏感信息的意外泄露。

示例:

  1. const schema = {
  2. response: {
  3. 200: {
  4. type: 'object',
  5. properties: {
  6. value: { type: 'string' },
  7. otherValue: { type: 'boolean' }
  8. }
  9. }
  10. }
  11. }
  12. fastify.post('/the/url', { schema }, handler)

如你所见,响应的 schema 是建立在状态码的基础之上的。当你想对多个状态码使用同一个 schema 时,你可以使用类似 '2xx' 的表达方法,例如:

  1. const schema = {
  2. response: {
  3. '2xx': {
  4. type: 'object',
  5. properties: {
  6. value: { type: 'string' },
  7. otherValue: { type: 'boolean' }
  8. }
  9. },
  10. 201: {
  11. // 对比写法
  12. value: { type: 'string' }
  13. }
  14. }
  15. }
  16. fastify.post('/the/url', { schema }, handler)

序列化函数生成器

serializerCompiler 返回一个根据输入参数返回字符串的函数。你应该提供一个函数,用于序列化所有定义了 response JSON Schema 的路由。

  1. fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => {
  2. return data => JSON.stringify(data)
  3. })
  4. fastify.get('/user', {
  5. handler (req, reply) {
  6. reply.send({ id: 1, name: 'Foo', image: 'BIG IMAGE' })
  7. },
  8. schema: {
  9. response: {
  10. '2xx': {
  11. id: { type: 'number' },
  12. name: { type: 'string' }
  13. }
  14. }
  15. }
  16. })

假如你需要在特定位置使用自定义的序列化工具,你可以使用 reply.serializer(...)

错误控制

当某个请求 schema 校验失败时,Fastify 会自动返回一个包含校验结果的 400 响应。举例来说,假如你的路由有一个如下的 schema:

  1. const schema = {
  2. body: {
  3. type: 'object',
  4. properties: {
  5. name: { type: 'string' }
  6. },
  7. required: ['name']
  8. }
  9. }

当校验失败时,路由会立即返回一个包含以下内容的响应:

  1. {
  2. "statusCode": 400,
  3. "error": "Bad Request",
  4. "message": "body should have required property 'name'"
  5. }

如果你想在路由内部控制错误,可以设置 attachValidation 选项。当出现 验证错误 时,请求的 validationError 属性将会包含一个 Error 对象,在这对象内部有原始的验证结果 validation,如下所示:

  1. const fastify = Fastify()
  2. fastify.post('/', { schema, attachValidation: true }, function (req, reply) {
  3. if (req.validationError) {
  4. // `req.validationError.validation` 包含了原始的验证错误信息
  5. reply.code(400).send(req.validationError)
  6. }
  7. })

schemaErrorFormatter

如果你需要自定义错误的格式化,可以给 Fastify 实例化时的选项添加 schemaErrorFormatter,其值为返回一个错误的同步函数。函数的 this 指向 Fastify 服务器实例。

errors 是 Fastify schema 错误 (FastifySchemaValidationError) 的一个数组。 dataVar 是当前验证的 schema 片段 (params | body | querystring | headers)。

  1. const fastify = Fastify({
  2. schemaErrorFormatter: (errors, dataVar) => {
  3. // ... 自定义的格式化逻辑
  4. return new Error(myErrorMessage)
  5. }
  6. })
  7. // 或
  8. fastify.setSchemaErrorFormatter(function (errors, dataVar) {
  9. this.log.error({ err: errors }, 'Validation failed')
  10. // ... 自定义的格式化逻辑
  11. return new Error(myErrorMessage)
  12. })

你还可以使用 setErrorHandler 方法来自定义一个校验错误响应,如下:

  1. fastify.setErrorHandler(function (error, request, reply) {
  2. if (error.validation) {
  3. // error.validationContext 是 [body, params, querystring, headers] 之中的值
  4. reply.status(422).send(new Error(`validation failed of the ${error.validationContext}`))
  5. }
  6. })

假如你想轻松愉快地自定义错误响应,请查看 ajv-errors。具体的例子可以移步这里

下面的例子展示了如何通过自定义 AJV,为 schema 的每个属性添加自定义错误信息。 其中的注释描述了在不同场景下设置不同信息的方法。

  1. const fastify = Fastify({
  2. ajv: {
  3. customOptions: { jsonPointers: true },
  4. plugins: [
  5. require('ajv-errors')
  6. ]
  7. }
  8. })
  9. const schema = {
  10. body: {
  11. type: 'object',
  12. properties: {
  13. name: {
  14. type: 'string',
  15. errorMessage: {
  16. type: 'Bad name'
  17. }
  18. },
  19. age: {
  20. type: 'number',
  21. errorMessage: {
  22. type: 'Bad age', // 为除了必填外的所有限制
  23. min: 'Too young' // 自定义错误信息
  24. }
  25. }
  26. },
  27. required: ['name', 'age'],
  28. errorMessage: {
  29. required: {
  30. name: 'Why no name!', // 为必填设置
  31. age: 'Why no age!' // 错误信息
  32. }
  33. }
  34. }
  35. }
  36. fastify.post('/', { schema, }, (request, reply) => {
  37. reply.send({
  38. hello: 'world'
  39. })
  40. })

想要本地化错误信息,请看 ajv-i18n

  1. const localize = require('ajv-i18n')
  2. const fastify = Fastify()
  3. const schema = {
  4. body: {
  5. type: 'object',
  6. properties: {
  7. name: {
  8. type: 'string',
  9. },
  10. age: {
  11. type: 'number',
  12. }
  13. },
  14. required: ['name', 'age'],
  15. }
  16. }
  17. fastify.setErrorHandler(function (error, request, reply) {
  18. if (error.validation) {
  19. localize.ru(error.validation)
  20. reply.status(400).send(error.validation)
  21. return
  22. }
  23. reply.send(error)
  24. })

JSON Schema 支持

为了能更简单地重用 schema,JSON Schema 提供了一些功能,来结合 Fastify 的共用 schema。

用例 验证器 序列化器
引用 ($ref) $id ✔️
引用 ($ref) /definitions ✔️ ✔️
引用 ($ref) 共用 schema $id ✔️
引用 ($ref) 共用 schema /definitions ✔️

示例

同一个 JSON Schema 中对 $id 的引用 ($ref)
  1. const refToId = {
  2. type: 'object',
  3. definitions: {
  4. foo: {
  5. $id: '#address',
  6. type: 'object',
  7. properties: {
  8. city: { type: 'string' }
  9. }
  10. }
  11. },
  12. properties: {
  13. home: { $ref: '#address' },
  14. work: { $ref: '#address' }
  15. }
  16. }
同一个 JSON Schema 中对 /definitions 的引用 ($ref)
  1. const refToDefinitions = {
  2. type: 'object',
  3. definitions: {
  4. foo: {
  5. $id: '#address',
  6. type: 'object',
  7. properties: {
  8. city: { type: 'string' }
  9. }
  10. }
  11. },
  12. properties: {
  13. home: { $ref: '#/definitions/foo' },
  14. work: { $ref: '#/definitions/foo' }
  15. }
  16. }
对外部共用 schema 的 $id 的引用 ($ref)
  1. fastify.addSchema({
  2. $id: 'http://foo/common.json',
  3. type: 'object',
  4. definitions: {
  5. foo: {
  6. $id: '#address',
  7. type: 'object',
  8. properties: {
  9. city: { type: 'string' }
  10. }
  11. }
  12. }
  13. })
  14. const refToSharedSchemaId = {
  15. type: 'object',
  16. properties: {
  17. home: { $ref: 'http://foo/common.json#address' },
  18. work: { $ref: 'http://foo/common.json#address' }
  19. }
  20. }
对外部共用 schema 的 /definitions 的引用 ($ref)
  1. fastify.addSchema({
  2. $id: 'http://foo/shared.json',
  3. type: 'object',
  4. definitions: {
  5. foo: {
  6. type: 'object',
  7. properties: {
  8. city: { type: 'string' }
  9. }
  10. }
  11. }
  12. })
  13. const refToSharedSchemaDefinitions = {
  14. type: 'object',
  15. properties: {
  16. home: { $ref: 'http://foo/shared.json#/definitions/foo' },
  17. work: { $ref: 'http://foo/shared.json#/definitions/foo' }
  18. }
  19. }

资源