Fastify 驗(yàn)證和序列化

2020-02-06 15:40 更新

驗(yàn)證和序列化

Fastify 使用基于 schema 的途徑,從本質(zhì)上將 schema 編譯成了高性能的函數(shù),來實(shí)現(xiàn)路由的驗(yàn)證與輸出的序列化。我們推薦使用 JSON Schema,雖然這并非必要。

? 安全須知應(yīng)當(dāng)將 schema 的定義寫入代碼。 因?yàn)椴还苁球?yàn)證還是序列化,都會(huì)使用 new Function() 來動(dòng)態(tài)生成代碼并執(zhí)行。 所以,用戶提供的 schema 是不安全的。 更多內(nèi)容,請(qǐng)看 Ajv 與 fast-json-stringify。

驗(yàn)證

路由的驗(yàn)證是依賴 Ajv 實(shí)現(xiàn)的。這是一個(gè)高性能的 JSON schema 校驗(yàn)工具。驗(yàn)證輸入十分簡單,只需將字段加入路由的 schema 中即可!支持的驗(yàn)證類型如下:

  • body:當(dāng)請(qǐng)求方法為 POST 或 PUT 時(shí),驗(yàn)證請(qǐng)求主體。
  • querystring 或 query:驗(yàn)證查詢字符串。可以是一個(gè)完整的 JSON Schema 對(duì)象 (包括值為 object 的 type 屬性以及包含參數(shù)的 properties 對(duì)象),也可以是一個(gè)只帶有查詢參數(shù) (無 type 與 properties 對(duì)象) 的簡單對(duì)象 (見下文示例)。
  • params:驗(yàn)證路由參數(shù)。
  • headers:驗(yàn)證請(qǐng)求頭部 (request headers)。

示例:

const bodyJsonSchema = {
  type: 'object',
  required: ['requiredKey'],
  properties: {
    someKey: { type: 'string' },
    someOtherKey: { type: 'number' },
    requiredKey: {
      type: 'array',
      maxItems: 3,
      items: { type: 'integer' }
    },
    nullableKey: { type: ['number', 'null'] }, // 或 { type: 'number', nullable: true }
    multipleTypesKey: { type: ['boolean', 'number'] },
    multipleRestrictedTypesKey: {
      oneOf: [
        { type: 'string', maxLength: 5 },
        { type: 'number', minimum: 10 }
      ]
    },
    enumKey: {
      type: 'string',
      enum: ['John', 'Foo']
    },
    notTypeKey: {
      not: { type: 'array' }
    }
  }
}

const queryStringJsonSchema = {
  name: { type: 'string' },
  excitement: { type: 'integer' }
}

const paramsJsonSchema = {
  type: 'object',
  properties: {
    par1: { type: 'string' },
    par2: { type: 'number' }
  }
}

const headersJsonSchema = {
  type: 'object',
  properties: {
    'x-foo': { type: 'string' }
  },
  required: ['x-foo']
}

const schema = {
  body: bodyJsonSchema,

  querystring: queryStringJsonSchema,

  params: paramsJsonSchema,

  headers: headersJsonSchema
}

fastify.post('/the/url', { schema }, handler)

請(qǐng)注意,Ajv 會(huì)嘗試將數(shù)據(jù)隱式轉(zhuǎn)換為 schema 中 type 屬性指明的類型。這么做的目的是通過校驗(yàn),并在后續(xù)過程中使用正確類型的數(shù)據(jù)。

添加共用 schema

感謝 addSchema API,它讓你可以向 Fastify 實(shí)例添加多個(gè) schema,并在你程序的不同部分使用它們。該 API 也是封裝好的。

有兩種方式可以復(fù)用你的共用 shema:

  • 使用$ref:正如 standard 中所述,你可以引用一份外部的 schema。做法是在 addSchema 的 $id 參數(shù)中指明外部 schema 的絕對(duì) URI。
  • 替換方式:Fastify 允許你使用共用 schema 替換某些字段。 你只需指明 addSchema 中的 $id 為相對(duì) URI 的 fragment (譯注:URI fragment是 URI 中 # 號(hào)后的部分) 即可,fragment 只接受字母與數(shù)字的組合[A-Za-z0-9]。

以下展示了你可以 如何 設(shè)置 $id 以及 如何 引用它:

  • 替換方式myField: 'foobar#' 會(huì)搜尋帶 $id: 'foobar' 的共用 schema
  • 使用$refmyField: { $ref: '#foo'} 會(huì)在當(dāng)前 schema 內(nèi)搜尋帶 $id: '#foo' 的字段myField: { $ref: '#/definitions/foo'} 會(huì)在當(dāng)前 schema 內(nèi)搜尋 definitions.foo 字段myField: { $ref: 'http://url.com/sh.json#'} 會(huì)搜尋帶 $id: 'http://url.com/sh.json' 的共用 schemamyField: { $ref: 'http://url.com/sh.json#/definitions/foo'} 會(huì)搜尋帶 $id: 'http://url.com/sh.json' 的共用 schema,并使用其 definitions.foo 字段myField: { $ref: 'http://url.com/sh.json#foo'} 會(huì)搜尋帶 $id: 'http://url.com/sh.json' 的共用 schema,并使用其內(nèi)部帶 $id: '#foo' 的對(duì)象

更多例子:

使用$ref 的例子:

fastify.addSchema({
  $id: 'http://example.com/common.json',
  type: 'object',
  properties: {
    hello: { type: 'string' }
  }
})

fastify.route({
  method: 'POST',
  url: '/',
  schema: {
    body: {
      type: 'array',
      items: { $ref: 'http://example.com/common.json#/properties/hello' }
    }
  },
  handler: () => {}
})

替換方式 的例子:

const fastify = require('fastify')()

fastify.addSchema({
  $id: 'greetings',
  type: 'object',
  properties: {
    hello: { type: 'string' }
  }
})

fastify.route({
  method: 'POST',
  url: '/',
  schema: {
    body: 'greetings#'
  },
  handler: () => {}
})

fastify.register((instance, opts, done) => {
  /**
  * 你可以在子作用域中使用在上層作用域里定義的 scheme,比如 'greetings'。
  * 父級(jí)作用域則無法使用子作用域定義的 schema。
  */
  instance.addSchema({
    $id: 'framework',
    type: 'object',
    properties: {
      fastest: { type: 'string' },
      hi: 'greetings#'
    }
  })
  instance.route({
    method: 'POST',
    url: '/sub',
    schema: {
      body: 'framework#'
    },
    handler: () => {}
  })
  done()
})

在任意位置你都能使用共用 schema,無論是在應(yīng)用頂層,還是在其他 schema 的內(nèi)部:

const fastify = require('fastify')()

fastify.addSchema({
  $id: 'greetings',
  type: 'object',
  properties: {
    hello: { type: 'string' }
  }
})

fastify.route({
  method: 'POST',
  url: '/',
  schema: {
    body: {
      type: 'object',
      properties: {
        greeting: 'greetings#',
        timestamp: { type: 'number' }
      }
    }
  },
  handler: () => {}
})

獲取共用 schema 的拷貝

getSchemas 函數(shù)返回指定作用域中的共用 schema:

fastify.addSchema({ $id: 'one', my: 'hello' })
fastify.get('/', (request, reply) => { reply.send(fastify.getSchemas()) })

fastify.register((instance, opts, done) => {
  instance.addSchema({ $id: 'two', my: 'ciao' })
  instance.get('/sub', (request, reply) => { reply.send(instance.getSchemas()) })

  instance.register((subinstance, opts, done) => {
    subinstance.addSchema({ $id: 'three', my: 'hola' })
    subinstance.get('/deep', (request, reply) => { reply.send(subinstance.getSchemas()) })
    done()
  })
  done()
})

這個(gè)例子的輸出如下:

URL Schemas
/ one
/sub one, two
/deep one, two, three

Ajv 插件

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

插件格式參見 ajv 選項(xiàng)
const fastify = require('fastify')({
  ajv: {
    plugins: [
      require('ajv-merge-patch')
    ]
  }
})
fastify.route({
  method: 'POST',
  url: '/',
  schema: {
    body: {
      $patch: {
        source: {
          type: 'object',
          properties: {
            q: {
              type: 'string'
            }
          }
        },
        with: [
          {
            op: 'add',
            path: '/properties/q',
            value: { type: 'number' }
          }
        ]
      }
    }
  },
  handler (req, reply) {
    reply.send({ ok: 1 })
  }
})
fastify.route({
  method: 'POST',
  url: '/',
  schema: {
    body: {
      $merge: {
        source: {
          type: 'object',
          properties: {
            q: {
              type: 'string'
            }
          }
        },
        with: {
          required: ['q']
        }
      }
    }
  },
  handler (req, reply) {
    reply.send({ ok: 1 })
  }
})

Schema 編譯器

schemaCompiler 返回一個(gè)用于驗(yàn)證請(qǐng)求主體、url 參數(shù)、header 以及查詢字符串的函數(shù)。默認(rèn)情況下,它返回一個(gè)實(shí)現(xiàn)了 ajv 驗(yàn)證接口的函數(shù)。Fastify 使用它對(duì)驗(yàn)證進(jìn)行加速。

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

{
  removeAdditional: true, // 移除額外屬性
  useDefaults: true, // 當(dāng)屬性或項(xiàng)目缺失時(shí),使用 schema 中預(yù)先定義好的 default 的值代替
  coerceTypes: true, // 根據(jù)定義的 type 的值改變數(shù)據(jù)類型
  allErrors: true,   // 檢查出所有錯(cuò)誤(譯注:為 false 時(shí)出現(xiàn)首個(gè)錯(cuò)誤后即返回)
  nullable: true     // 支持 OpenAPI Specification 3.0 版本的 "nullable" 關(guān)鍵字
}

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

假如你想改變或增加額外的選項(xiàng),你需要?jiǎng)?chuàng)建一個(gè)自定義的實(shí)例,并覆蓋已存在的實(shí)例:

const fastify = require('fastify')()
const Ajv = require('ajv')
const ajv = new Ajv({
  // fastify 使用的默認(rèn)參數(shù)(如果需要)
  removeAdditional: true,
  useDefaults: true,
  coerceTypes: true,
  allErrors: true,
  nullable: true,
  // 任意其他參數(shù)
  // ...
})
fastify.setSchemaCompiler(function (schema) {
  return ajv.compile(schema)
})

// -------
// 此外,你還可以通過 setter 方法來設(shè)置 schema 編譯器:
fastify.schemaCompiler = function (schema) { return ajv.compile(schema) })

使用其他驗(yàn)證工具

通過 schemaCompiler 函數(shù),你可以輕松地將 ajv 替換為幾乎任意的 Javascript 驗(yàn)證工具 (如 joi、yup 等)。

然而,為了更好地與 Fastify 的 request/response 相適應(yīng),schemaCompiler 返回的函數(shù)應(yīng)該返回一個(gè)包含以下屬性的對(duì)象:

  • error 屬性,其值為 Error 的實(shí)例,或描述校驗(yàn)錯(cuò)誤的字符串,當(dāng)驗(yàn)證失敗時(shí)使用。
  • value 屬性,其值為驗(yàn)證后的隱式轉(zhuǎn)換過的數(shù)據(jù),驗(yàn)證成功時(shí)使用。

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

const joi = require('joi')

// 等同于前文 ajv 基本配置的 joi 的配置
const joiOptions = {
  abortEarly: false, // 返回所有錯(cuò)誤 (譯注:為 true 時(shí)出現(xiàn)首個(gè)錯(cuò)誤后即返回)
  convert: true, // 根據(jù)定義的 type 的值改變數(shù)據(jù)類型
  allowUnknown : false, // 移除額外屬性
  noDefaults: false
}

const joiBodySchema = joi.object().keys({
  age: joi.number().integer().required(),
  sub: joi.object().keys({
    name: joi.string().required()
  }).required()
})

const joiSchemaCompiler = schema => data => {
  // joi 的 `validate` 函數(shù)返回一個(gè)對(duì)象。當(dāng)驗(yàn)證失敗時(shí),該對(duì)象具有 error 屬性,并永遠(yuǎn)都有一個(gè) value 屬性,當(dāng)驗(yàn)證成功后,會(huì)存有隱式轉(zhuǎn)換后的值。
  const { error, value } = joiSchema.validate(data, joiOptions)
  if (error) {
    return { error }
  } else {
    return { value }
  }
}

// 更簡潔的寫法
const joiSchemaCompiler = schema => data => joiSchema.validate(data, joiOptions)

fastify.post('/the/url', {
  schema: {
    body: joiBodySchema
  },
  schemaCompiler: joiSchemaCompiler
}, handler)
const yup = require('yup')

// 等同于前文 ajv 基本配置的 yup 的配置
const yupOptions = {
  strict: false,
  abortEarly: false, // 返回所有錯(cuò)誤(譯注:為 true 時(shí)出現(xiàn)首個(gè)錯(cuò)誤后即返回)
  stripUnknown: true, // 移除額外屬性
  recursive: true
}

const yupBodySchema = yup.object({
  age: yup.number().integer().required(),
  sub: yup.object().shape({
    name: yup.string().required()
  }).required()
})

const yupSchemaCompiler = schema => data => {
  // 當(dāng)設(shè)置 strict = false 時(shí), yup 的 `validateSync` 函數(shù)在驗(yàn)證成功后會(huì)返回經(jīng)過轉(zhuǎn)換的值,而失敗時(shí)則會(huì)拋錯(cuò)。
  try {
    const result = schema.validateSync(data, yupOptions)
    return { value: result }
  } catch (e) {
    return { error: e }
  }
}

fastify.post('/the/url', {
  schema: {
    body: yupBodySchema
  },
  schemaCompiler: yupSchemaCompiler
}, handler)
其他驗(yàn)證工具與驗(yàn)證信息

Fastify 的錯(cuò)誤驗(yàn)證與其默認(rèn)的驗(yàn)證引擎 ajv 緊密結(jié)合,錯(cuò)誤最終會(huì)經(jīng)由 schemaErrorsText 函數(shù)轉(zhuǎn)化為便于閱讀的信息。然而,也正是由于 schemaErrorsText 與 ajv 的強(qiáng)關(guān)聯(lián)性,當(dāng)你使用其他校驗(yàn)工具時(shí),可能會(huì)出現(xiàn)奇怪或不完整的錯(cuò)誤信息。

要規(guī)避以上問題,主要有兩個(gè)途徑:

  1. 確保自定義的 schemaCompiler 返回的錯(cuò)誤結(jié)構(gòu)與 ajv 的一致 (當(dāng)然,由于各引擎的差異,這是件困難的活兒)。
  2. 使用自定義的 errorHandler 攔截并格式化驗(yàn)證錯(cuò)誤。

Fastify 給所有的驗(yàn)證錯(cuò)誤添加了兩個(gè)屬性,來幫助你自定義 errorHandler:

  • validation:來自 schemaCompiler 函數(shù)的驗(yàn)證函數(shù)所返回的對(duì)象上的 error 屬性的內(nèi)容。
  • validationContext:驗(yàn)證錯(cuò)誤的上下文 (body、params、query、headers)。

以下是一個(gè)自定義 errorHandler 來處理驗(yàn)證錯(cuò)誤的例子:

const errorHandler = (error, request, reply) => {

  const statusCode = error.statusCode
  let response

  const { validation, validationContext } = error

  // 檢驗(yàn)是否發(fā)生了驗(yàn)證錯(cuò)誤
  if (validation) {
    response = {
      message: `A validation error occured when validating the ${validationContext}...`, // validationContext 的值可能是 'body'、'params'、'headers' 或 'query'
      errors: validation // 驗(yàn)證工具返回的結(jié)果
    }
  } else {
    response = {
      message: 'An error occurred...'
    }
  }

  // 其余代碼。例如,記錄錯(cuò)誤日志。
  // ...

  reply.status(statusCode).send(response)

}

Schema 解析器

schemaResolver 需要與 schemaCompiler 結(jié)合起來使用,你不能在使用默認(rèn)的 schema 編譯器時(shí)使用它。當(dāng)你的路由中有包含 #ref 關(guān)鍵字的復(fù)雜 schema 時(shí),且使用自定義校驗(yàn)器時(shí),它能派上用場(chǎng)。

這是因?yàn)?,?duì)于 Fastify 而言,添加到自定義編譯器的 schema 都是未知的,但是 $ref 路徑卻需要被解析。

const fastify = require('fastify')()
const Ajv = require('ajv')
const ajv = new Ajv()
ajv.addSchema({
  $id: 'urn:schema:foo',
  definitions: {
    foo: { type: 'string' }
  },
  type: 'object',
  properties: {
    foo: { $ref: '#/definitions/foo' }
  }
})
ajv.addSchema({
  $id: 'urn:schema:response',
  type: 'object',
  required: ['foo'],
  properties: {
    foo: { $ref: 'urn:schema:foo#/definitions/foo' }
  }
})
ajv.addSchema({
  $id: 'urn:schema:request',
  type: 'object',
  required: ['foo'],
  properties: {
    foo: { $ref: 'urn:schema:foo#/definitions/foo' }
  }
})
fastify.setSchemaCompiler(schema => ajv.compile(schema))
fastify.setSchemaResolver((ref) => {
  return ajv.getSchema(ref).schema
})
fastify.route({
  method: 'POST',
  url: '/',
  schema: {
    body: ajv.getSchema('urn:schema:request').schema,
    response: {
      '2xx': ajv.getSchema('urn:schema:response').schema
    }
  },
  handler (req, reply) {
    reply.send({ foo: 'bar' })
  }
})

序列化

通常,你會(huì)通過 JSON 格式將數(shù)據(jù)發(fā)送至客戶端。鑒于此,F(xiàn)astify 提供了一個(gè)強(qiáng)大的工具——fast-json-stringify 來幫助你。當(dāng)你提供了輸出的 schema 時(shí),它能派上用場(chǎng)。我們推薦你編寫一個(gè)輸出的 schema,因?yàn)檫@能讓應(yīng)用的吞吐量提升 100-400% (根據(jù) payload 的不同而有所變化),也能防止敏感信息的意外泄露。

示例:

const schema = {
  response: {
    200: {
      type: 'object',
      properties: {
        value: { type: 'string' },
        otherValue: { type: 'boolean' }
      }
    }
  }
}

fastify.post('/the/url', { schema }, handler)

如你所見,響應(yīng)的 schema 是建立在狀態(tài)碼的基礎(chǔ)之上的。當(dāng)你想對(duì)多個(gè)狀態(tài)碼使用同一個(gè) schema 時(shí),你可以使用類似 '2xx' 的表達(dá)方法,例如:

const schema = {
  response: {
    '2xx': {
      type: 'object',
      properties: {
        value: { type: 'string' },
        otherValue: { type: 'boolean' }
      }
    },
    201: {
      type: 'object',
      properties: {
        value: { type: 'string' }
      }
    }
  }
}

fastify.post('/the/url', { schema }, handler)

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

錯(cuò)誤控制

當(dāng)某個(gè)請(qǐng)求 schema 校驗(yàn)失敗時(shí),F(xiàn)astify 會(huì)自動(dòng)返回一個(gè)包含校驗(yàn)結(jié)果的 400 響應(yīng)。舉例來說,假如你的路由有一個(gè)如下的 schema:

const schema = {
  body: {
    type: 'object',
    properties: {
      name: { type: 'string' }
    },
    required: ['name']
  }
}

當(dāng)校驗(yàn)失敗時(shí),路由會(huì)立即返回一個(gè)包含以下內(nèi)容的響應(yīng):

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "body should have required property 'name'"
}

如果你想在路由內(nèi)部控制錯(cuò)誤,可以設(shè)置 attachValidation 選項(xiàng)。當(dāng)出現(xiàn)驗(yàn)證錯(cuò)誤時(shí),請(qǐng)求的 validationError 屬性將會(huì)包含一個(gè) Error 對(duì)象,在這對(duì)象內(nèi)部有原始的驗(yàn)證結(jié)果 validation,如下所示:

const fastify = Fastify()
 fastify.post('/', { schema, attachValidation: true }, function (req, reply) {
  if (req.validationError) {
    // `req.validationError.validation` 包含了原始的驗(yàn)證錯(cuò)誤信息
    reply.code(400).send(req.validationError)
  }
})

你還可以使用 setErrorHandler 方法來自定義一個(gè)校驗(yàn)錯(cuò)誤響應(yīng),如下:

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

假如你想輕松愉快地自定義錯(cuò)誤響應(yīng),可以看這里

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

為了能更簡單地重用 schema,JSON Schema 提供了一些功能,來結(jié)合 Fastify 的共用 schema。

用例 驗(yàn)證器 序列化器
共用 schema ?? ??
引用 ($ref$id ? ??
引用 ($ref/definitions ?? ??
引用 ($ref) 共用 schema $id ? ??
引用 ($ref) 共用 schema /definitions ? ??

示例

// 共用 Schema 的用例
fastify.addSchema({
  $id: 'sharedAddress',
  type: 'object',
  properties: {
    city: { 'type': 'string' }
  }
})

const sharedSchema = {
  type: 'object',
  properties: {
    home: 'sharedAddress#',
    work: 'sharedAddress#'
  }
}
// 同一 JSON Schema 內(nèi)部對(duì) $id 的引用 ($ref)
const refToId = {
  type: 'object',
  definitions: {
    foo: {
      $id: '#address',
      type: 'object',
      properties: {
        city: { 'type': 'string' }
      }
    }
  },
  properties: {
    home: { $ref: '#address' },
    work: { $ref: '#address' }
  }
}
// 同一 JSON Schema 內(nèi)部對(duì) /definitions 的引用 ($ref)
const refToDefinitions = {
  type: 'object',
  definitions: {
    foo: {
      $id: '#address',
      type: 'object',
      properties: {
        city: { 'type': 'string' }
      }
    }
  },
  properties: {
    home: { $ref: '#/definitions/foo' },
    work: { $ref: '#/definitions/foo' }
  }
}
// 對(duì)外部共用 schema 的 $id 的引用 ($ref)
fastify.addSchema({
  $id: 'http://foo/common.json',
  type: 'object',
  definitions: {
    foo: {
      $id: '#address',
      type: 'object',
      properties: {
        city: { 'type': 'string' }
      }
    }
  }
})

const refToSharedSchemaId = {
  type: 'object',
  properties: {
    home: { $ref: 'http://foo/common.json#address' },
    work: { $ref: 'http://foo/common.json#address' }
  }
}
// 對(duì)外部共用 schema 的 /definitions 的引用 ($ref)
fastify.addSchema({
  $id: 'http://foo/common.json',
  type: 'object',
  definitions: {
    foo: {
      type: 'object',
      properties: {
        city: { 'type': 'string' }
      }
    }
  }
})

const refToSharedSchemaDefinitions = {
  type: 'object',
  properties: {
    home: { $ref: 'http://foo/common.json#/definitions/foo' },
    work: { $ref: 'http://foo/common.json#/definitions/foo' }
  }
}

資源


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)