Fastify

验证和序列化

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

⚠ 安全须知

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

验证

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

  • body:当请求方法为 POST 或 PUT 时,验证请求主体。
  • querystringquery:验证查询字符串。可以是一个完整的 JSON Schema 对象 (包括值为 objecttype 属性以及包含参数的 properties 对象),也可以是一个只带有查询参数 (无 typeproperties 对象) 的简单对象 (见下文示例)。
  • params:验证路由参数。
  • headers:验证请求头部 (request headers)。

示例:

  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. required: ['name'],
  32. properties: {
  33. name: { type: 'string' },
  34. excitement: { type: 'integer' }
  35. }
  36. }
  37. /* 如果不需指明必填字段,
  38. * 也可使用简化的语法:
  39. const queryStringJsonSchema = {
  40. name: { type: 'string' },
  41. excitement: { type: 'integer' }
  42. }
  43. */
  44. const paramsJsonSchema = {
  45. type: 'object',
  46. properties: {
  47. par1: { type: 'string' },
  48. par2: { type: 'number' }
  49. }
  50. }
  51. const headersJsonSchema = {
  52. type: 'object',
  53. properties: {
  54. 'x-foo': { type: 'string' }
  55. },
  56. required: ['x-foo']
  57. }
  58. const schema = {
  59. body: bodyJsonSchema,
  60. querystring: queryStringJsonSchema,
  61. params: paramsJsonSchema,
  62. headers: headersJsonSchema
  63. }
  64. fastify.post('/the/url', { schema }, handler)

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

添加共用 schema

感谢 addSchema API,它让你可以向 Fastify 实例添加多个 schema,并在你程序的不同部分使用它们。该 API 也是封装好的。

有两种方式可以复用你的共用 shema:

  • 使用$ref:正如 standard 中所述,你可以引用一份外部的 schema。做法是在 addSchema$id 参数中指明外部 schema 的绝对 URI。
  • 替换方式:Fastify 允许你使用共用 schema 替换某些字段。 你只需指明 addSchema 中的 $id 为相对 URI 的 fragment (译注:URI fragment是 URI 中 # 号后的部分) 即可,fragment 只接受字母与数字的组合[A-Za-z0-9]

以下展示了你可以 如何 设置 $id 以及 如何 引用它:

  • 替换方式
    • myField: 'foobar#' 会搜寻带 $id: 'foobar' 的共用 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' 的对象

更多例子:

使用$ref 的例子:

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

替换方式 的例子:

  1. const fastify = require('fastify')()
  2. fastify.addSchema({
  3. $id: 'greetings',
  4. type: 'object',
  5. properties: {
  6. hello: { type: 'string' }
  7. }
  8. })
  9. fastify.route({
  10. method: 'POST',
  11. url: '/',
  12. schema: {
  13. body: 'greetings#'
  14. },
  15. handler: () => {}
  16. })
  17. fastify.register((instance, opts, done) => {
  18. /**
  19. * 你可以在子作用域中使用在上层作用域里定义的 scheme,比如 'greetings'。
  20. * 父级作用域则无法使用子作用域定义的 schema。
  21. */
  22. instance.addSchema({
  23. $id: 'framework',
  24. type: 'object',
  25. properties: {
  26. fastest: { type: 'string' },
  27. hi: 'greetings#'
  28. }
  29. })
  30. instance.route({
  31. method: 'POST',
  32. url: '/sub',
  33. schema: {
  34. body: 'framework#'
  35. },
  36. handler: () => {}
  37. })
  38. done()
  39. })

在任意位置你都能使用共用 schema,无论是在应用顶层,还是在其他 schema 的内部:

  1. const fastify = require('fastify')()
  2. fastify.addSchema({
  3. $id: 'greetings',
  4. type: 'object',
  5. properties: {
  6. hello: { type: 'string' }
  7. }
  8. })
  9. fastify.route({
  10. method: 'POST',
  11. url: '/',
  12. schema: {
  13. body: {
  14. type: 'object',
  15. properties: {
  16. greeting: 'greetings#',
  17. timestamp: { type: 'number' }
  18. }
  19. }
  20. },
  21. handler: () => {}
  22. })

获取共用 schema 的拷贝

getSchemas 函数返回指定作用域中的共用 schema:

  1. fastify.addSchema({ $id: 'one', my: 'hello' })
  2. fastify.get('/', (request, reply) => { reply.send(fastify.getSchemas()) })
  3. fastify.register((instance, opts, done) => {
  4. instance.addSchema({ $id: 'two', my: 'ciao' })
  5. instance.get('/sub', (request, reply) => { reply.send(instance.getSchemas()) })
  6. instance.register((subinstance, opts, done) => {
  7. subinstance.addSchema({ $id: 'three', my: 'hola' })
  8. subinstance.get('/deep', (request, reply) => { reply.send(subinstance.getSchemas()) })
  9. done()
  10. })
  11. done()
  12. })

这个例子的输出如下:

URLSchemas
/one
/subone, two
/deepone, two, three

Ajv 插件

你可以提供一组用于 Ajv 的插件:

插件格式参见 ajv 选项

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

Schema 编译器

schemaCompiler 返回一个用于验证请求主体、url 参数、header 以及查询字符串的函数。默认情况下,它返回一个实现了 ajv 验证接口的函数。Fastify 使用它对验证进行加速。

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

  1. {
  2. removeAdditional: true, // 移除额外属性
  3. useDefaults: true, // 当属性或项目缺失时,使用 schema 中预先定义好的 default 的值代替
  4. coerceTypes: true, // 根据定义的 type 的值改变数据类型
  5. allErrors: true, // 检查出所有错误(译注:为 false 时出现首个错误后即返回)
  6. nullable: true // 支持 OpenAPI Specification 3.0 版本的 "nullable" 关键字
  7. }

上述配置可通过 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. allErrors: true,
  9. nullable: true,
  10. // 任意其他参数
  11. // ...
  12. })
  13. fastify.setSchemaCompiler(function (schema) {
  14. return ajv.compile(schema)
  15. })
  16. // -------
  17. // 此外,你还可以通过 setter 方法来设置 schema 编译器:
  18. fastify.schemaCompiler = function (schema) { return ajv.compile(schema) })

使用其他验证工具

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

然而,为了更好地与 Fastify 的 request/response 相适应,schemaCompiler 返回的函数应该返回一个包含以下属性的对象:

  • error 属性,其值为 Error 的实例,或描述校验错误的字符串,当验证失败时使用。
  • value 属性,其值为验证后的隐式转换过的数据,验证成功时使用。

因此,下面的例子和使用 ajv 是一致的:

  1. const joi = require('joi')
  2. // 等同于前文 ajv 基本配置的 joi 的配置
  3. const joiOptions = {
  4. abortEarly: false, // 返回所有错误 (译注:为 true 时出现首个错误后即返回)
  5. convert: true, // 根据定义的 type 的值改变数据类型
  6. allowUnknown : false, // 移除额外属性
  7. noDefaults: false
  8. }
  9. const joiBodySchema = joi.object().keys({
  10. age: joi.number().integer().required(),
  11. sub: joi.object().keys({
  12. name: joi.string().required()
  13. }).required()
  14. })
  15. const joiSchemaCompiler = schema => data => {
  16. // joi 的 `validate` 函数返回一个对象。当验证失败时,该对象具有 error 属性,并永远都有一个 value 属性,当验证成功后,会存有隐式转换后的值。
  17. const { error, value } = joiSchema.validate(data, joiOptions)
  18. if (error) {
  19. return { error }
  20. } else {
  21. return { value }
  22. }
  23. }
  24. // 更简洁的写法
  25. const joiSchemaCompiler = schema => data => joiSchema.validate(data, joiOptions)
  26. fastify.post('/the/url', {
  27. schema: {
  28. body: joiBodySchema
  29. },
  30. schemaCompiler: joiSchemaCompiler
  31. }, 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. const yupBodySchema = yup.object({
  10. age: yup.number().integer().required(),
  11. sub: yup.object().shape({
  12. name: yup.string().required()
  13. }).required()
  14. })
  15. const yupSchemaCompiler = schema => data => {
  16. // 当设置 strict = false 时, yup 的 `validateSync` 函数在验证成功后会返回经过转换的值,而失败时则会抛错。
  17. try {
  18. const result = schema.validateSync(data, yupOptions)
  19. return { value: result }
  20. } catch (e) {
  21. return { error: e }
  22. }
  23. }
  24. fastify.post('/the/url', {
  25. schema: {
  26. body: yupBodySchema
  27. },
  28. schemaCompiler: yupSchemaCompiler
  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. message: `A validation error occured when validating the ${validationContext}...`, // validationContext 的值可能是 'body'、'params'、'headers' 或 'query'
  9. errors: validation // 验证工具返回的结果
  10. }
  11. } else {
  12. response = {
  13. message: 'An error occurred...'
  14. }
  15. }
  16. // 其余代码。例如,记录错误日志。
  17. // ...
  18. reply.status(statusCode).send(response)
  19. }

Schema 解析器

schemaResolver 需要与 schemaCompiler 结合起来使用,你不能在使用默认的 schema 编译器时使用它。当你的路由中有包含 #ref 关键字的复杂 schema 时,且使用自定义校验器时,它能派上用场。

这是因为,对于 Fastify 而言,添加到自定义编译器的 schema 都是未知的,但是 $ref 路径却需要被解析。

  1. const fastify = require('fastify')()
  2. const Ajv = require('ajv')
  3. const ajv = new Ajv()
  4. ajv.addSchema({
  5. $id: 'urn:schema:foo',
  6. definitions: {
  7. foo: { type: 'string' }
  8. },
  9. type: 'object',
  10. properties: {
  11. foo: { $ref: '#/definitions/foo' }
  12. }
  13. })
  14. ajv.addSchema({
  15. $id: 'urn:schema:response',
  16. type: 'object',
  17. required: ['foo'],
  18. properties: {
  19. foo: { $ref: 'urn:schema:foo#/definitions/foo' }
  20. }
  21. })
  22. ajv.addSchema({
  23. $id: 'urn:schema:request',
  24. type: 'object',
  25. required: ['foo'],
  26. properties: {
  27. foo: { $ref: 'urn:schema:foo#/definitions/foo' }
  28. }
  29. })
  30. fastify.setSchemaCompiler(schema => ajv.compile(schema))
  31. fastify.setSchemaResolver((ref) => {
  32. return ajv.getSchema(ref).schema
  33. })
  34. fastify.route({
  35. method: 'POST',
  36. url: '/',
  37. schema: {
  38. body: { $ref: 'urn:schema:request#' },
  39. response: {
  40. '2xx':{ $ref: 'urn:schema:response#' }
  41. }
  42. },
  43. handler (req, reply) {
  44. reply.send({ foo: 'bar' })
  45. }
  46. })

序列化

通常,你会通过 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. type: 'object',
  12. properties: {
  13. value: { type: 'string' }
  14. }
  15. }
  16. }
  17. }
  18. fastify.post('/the/url', { schema }, handler)

假如你需要在特定位置使用自定义的序列化工具,你可以使用 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. })

你还可以使用 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: { allErrors: true, 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. ajv: {
  4. customOptions: { allErrors: true }
  5. }
  6. })
  7. const schema = {
  8. body: {
  9. type: 'object',
  10. properties: {
  11. name: {
  12. type: 'string',
  13. },
  14. age: {
  15. type: 'number',
  16. }
  17. },
  18. required: ['name', 'age'],
  19. }
  20. }
  21. fastify.setErrorHandler(function (error, request, reply) {
  22. if (error.validation) {
  23. localize.ru(error.validation)
  24. reply.status(400).send(error.validation)
  25. return
  26. }
  27. reply.send(error)
  28. })

JSON Schema 及共用 Schema (Shared Schema) 支持

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

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

示例

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

资源