Fastify 驗證和序列化

2020-02-06 15:40 更新

驗證和序列化

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

? 安全須知應當將 schema 的定義寫入代碼。 因為不管是驗證還是序列化,都會使用 new Function() 來動態(tài)生成代碼并執(zhí)行。 所以,用戶提供的 schema 是不安全的。 更多內容,請看 Ajv 與 fast-json-stringify。

驗證

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

  • body:當請求方法為 POST 或 PUT 時,驗證請求主體。
  • querystring 或 query:驗證查詢字符串。可以是一個完整的 JSON Schema 對象 (包括值為 object 的 type 屬性以及包含參數(shù)的 properties 對象),也可以是一個只帶有查詢參數(shù) (無 type 與 properties 對象) 的簡單對象 (見下文示例)。
  • params:驗證路由參數(shù)。
  • headers:驗證請求頭部 (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ù)隱式轉換為 schema 中 type 屬性指明的類型。這么做的目的是通過校驗,并在后續(xù)過程中使用正確類型的數(shù)據(jù)。

添加共用 schema

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

有兩種方式可以復用你的共用 shema:

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

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

  • 替換方式myField: 'foobar#' 會搜尋帶 $id: 'foobar' 的共用 schema
  • 使用$refmyField: { $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' 的共用 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,并使用其內部帶 $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,無論是在應用頂層,還是在其他 schema 的內部:

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

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

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

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

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

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

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

使用其他驗證工具

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

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

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

因此,下面的例子和使用 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ù)返回一個對象。當驗證失敗時,該對象具有 error 屬性,并永遠都有一個 value 屬性,當驗證成功后,會存有隱式轉換后的值。
  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 => {
  // 當設置 strict = false 時, yup 的 `validateSync` 函數(shù)在驗證成功后會返回經(jīng)過轉換的值,而失敗時則會拋錯。
  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)
其他驗證工具與驗證信息

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

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

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

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

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

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

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

  const statusCode = error.statusCode
  let response

  const { validation, validationContext } = error

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

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

  reply.status(statusCode).send(response)

}

Schema 解析器

schemaResolver 需要與 schemaCompiler 結合起來使用,你不能在使用默認的 schema 編譯器時使用它。當你的路由中有包含 #ref 關鍵字的復雜 schema 時,且使用自定義校驗器時,它能派上用場。

這是因為,對于 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 提供了一個強大的工具——fast-json-stringify 來幫助你。當你提供了輸出的 schema 時,它能派上用場。我們推薦你編寫一個輸出的 schema,因為這能讓應用的吞吐量提升 100-400% (根據(jù) payload 的不同而有所變化),也能防止敏感信息的意外泄露。

示例:

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

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

如你所見,響應的 schema 是建立在狀態(tài)碼的基礎之上的。當你想對多個狀態(tài)碼使用同一個 schema 時,你可以使用類似 '2xx' 的表達方法,例如:

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(...)。

錯誤控制

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

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

當校驗失敗時,路由會立即返回一個包含以下內容的響應:

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

如果你想在路由內部控制錯誤,可以設置 attachValidation 選項。當出現(xiàn)驗證錯誤時,請求的 validationError 屬性將會包含一個 Error 對象,在這對象內部有原始的驗證結果 validation,如下所示:

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

你還可以使用 setErrorHandler 方法來自定義一個校驗錯誤響應,如下:

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}`))
  }
})

假如你想輕松愉快地自定義錯誤響應,可以看這里。

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

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

用例 驗證器 序列化器
共用 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 內部對 $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 內部對 /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' }
  }
}

資源


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號