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)證還是序列化,都會使用 new Function() 來動態(tài)生成代碼并執(zhí)行。 所以,用戶提供的 schema 是不安全的。 更多內(nèi)容,請看 Ajv 與 fast-json-stringify。

驗(yàn)證

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

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

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

添加共用 schema

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

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

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

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

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

更多例子:

使用$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'。
  * 父級作用域則無法使用子作用域定義的 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()
})

這個例子的輸出如下:

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

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

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

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

假如你想改變或增加額外的選項(xiàng),你需要創(chuàng)建一個自定義的實(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)該返回一個包含以下屬性的對象:

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

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

const joi = require('joi')

// 等同于前文 ajv 基本配置的 joi 的配置
const joiOptions = {
  abortEarly: false, // 返回所有錯誤 (譯注:為 true 時出現(xiàn)首個錯誤后即返回)
  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ù)返回一個對象。當(dāng)驗(yàn)證失敗時,該對象具有 error 屬性,并永遠(yuǎn)都有一個 value 屬性,當(dāng)驗(yàn)證成功后,會存有隱式轉(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, // 返回所有錯誤(譯注:為 true 時出現(xiàn)首個錯誤后即返回)
  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 時, yup 的 `validateSync` 函數(shù)在驗(yàn)證成功后會返回經(jīng)過轉(zhuǎn)換的值,而失敗時則會拋錯。
  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 的錯誤驗(yàn)證與其默認(rèn)的驗(yàn)證引擎 ajv 緊密結(jié)合,錯誤最終會經(jīng)由 schemaErrorsText 函數(shù)轉(zhuǎn)化為便于閱讀的信息。然而,也正是由于 schemaErrorsText 與 ajv 的強(qiáng)關(guān)聯(lián)性,當(dāng)你使用其他校驗(yàn)工具時,可能會出現(xiàn)奇怪或不完整的錯誤信息。

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

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

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

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

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

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

  const statusCode = error.statusCode
  let response

  const { validation, validationContext } = error

  // 檢驗(yàn)是否發(fā)生了驗(yàn)證錯誤
  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...'
    }
  }

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

  reply.status(statusCode).send(response)

}

Schema 解析器

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

這是因?yàn)?,對?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' })
  }
})

序列化

通常,你會通過 JSON 格式將數(shù)據(jù)發(fā)送至客戶端。鑒于此,F(xiàn)astify 提供了一個強(qiáng)大的工具——fast-json-stringify 來幫助你。當(dāng)你提供了輸出的 schema 時,它能派上用場。我們推薦你編寫一個輸出的 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)你想對多個狀態(tài)碼使用同一個 schema 時,你可以使用類似 '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(...)。

錯誤控制

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

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

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

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

如果你想在路由內(nèi)部控制錯誤,可以設(shè)置 attachValidation 選項(xiàng)。當(dāng)出現(xiàn)驗(yàn)證錯誤時,請求的 validationError 屬性將會包含一個 Error 對象,在這對象內(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)證錯誤信息
    reply.code(400).send(req.validationError)
  }
})

你還可以使用 setErrorHandler 方法來自定義一個校驗(yàn)錯誤響應(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}`))
  }
})

假如你想輕松愉快地自定義錯誤響應(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)部對 $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)部對 /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' }
  }
}
// 對外部共用 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' }
  }
}
// 對外部共用 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)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號