yuheng/doc/fastify-docs/docs/Validation-and-Serialization.md
2025-03-19 15:54:28 +08:00

23 KiB
Raw Permalink Blame History

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' 的对象。

简单用法:

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

fastify.post("/", {
  handler() {},
  schema: {
    body: {
      type: "array",
      items: { $ref: "http://example.com#/properties/hello" },
    },
  },
});

$ref 作为根引用 (root reference)

fastify.addSchema({
  $id: "commonSchema",
  type: "object",
  properties: {
    hello: { type: "string" },
  },
});

fastify.post("/", {
  handler() {},
  schema: {
    body: { $ref: "commonSchema#" },
    headers: { $ref: "commonSchema#" },
  },
});

获取共用 schema

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

fastify.addSchema({
  $id: "schemaId",
  type: "object",
  properties: {
    hello: { type: "string" },
  },
});

const mySchemas = fastify.getSchemas();
const mySchema = fastify.getSchema("schemaId");

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

fastify.addSchema({ $id: "one", my: "hello" });
// 只返回 schema `one`
fastify.get("/", (request, reply) => {
  reply.send(fastify.getSchemas());
});

fastify.register((instance, opts, done) => {
  instance.addSchema({ $id: "two", my: "ciao" });
  // 会返回 schema `one` 与 `two`
  instance.get("/sub", (request, reply) => {
    reply.send(instance.getSchemas());
  });

  instance.register((subinstance, opts, done) => {
    subinstance.addSchema({ $id: "three", my: "hola" });
    // 会返回 schema `one`、`two` 和 `three`
    subinstance.get("/deep", (request, reply) => {
      reply.send(subinstance.getSchemas());
    });
    done();
  });
  done();
});

验证

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

支持的验证类型如下:

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

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

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

示例:

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 = {
  type: "object",
  properties: {
    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 会尝试将数据隐式转换为 schema 中 type 属性指明的类型。

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

const opts = {
  schema: {
    querystring: {
      type: "object",
      properties: {
        ids: {
          type: "array",
          default: [],
        },
      },
    },
  },
};

fastify.get("/", opts, (request, reply) => {
  reply.send({ params: request.query });
});

fastify.listen(3000, (err) => {
  if (err) throw err;
});

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

curl -X GET "http://localhost:3000/?ids=1

{"statusCode":400,"error":"Bad Request","message":"querystring/hello should be array"}

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

const ajv = new Ajv({
  removeAdditional: true,
  useDefaults: true,
  coerceTypes: "array", // 看这里
  allErrors: true,
});

fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
  return ajv.compile(schema);
});
curl -X GET "http://localhost:3000/?ids=1

{"params":{"hello":["1"]}}

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

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

const schemaCompilers = {
  body: new Ajv({
    removeAdditional: false,
    coerceTypes: false,
    allErrors: true,
  }),
  params: new Ajv({
    removeAdditional: false,
    coerceTypes: true,
    allErrors: true,
  }),
  querystring: new Ajv({
    removeAdditional: false,
    coerceTypes: true,
    allErrors: true,
  }),
  headers: new Ajv({
    removeAdditional: false,
    coerceTypes: true,
    allErrors: true,
  }),
};

server.setValidatorCompiler((req) => {
  if (!req.httpPart) {
    throw new Error("Missing httpPart");
  }
  const compiler = schemaCompilers[req.httpPart];
  if (!compiler) {
    throw new Error(`Missing compiler for ${req.httpPart}`);
  }
  return compiler.compile(req.schema);
});

更多信息请看这里

Ajv 插件

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

插件格式参见 ajv 选项

const fastify = require("fastify")({
  ajv: {
    plugins: [require("ajv-merge-patch")],
  },
});

fastify.post("/", {
  handler(req, reply) {
    reply.send({ ok: 1 });
  },
  schema: {
    body: {
      $patch: {
        source: {
          type: "object",
          properties: {
            q: {
              type: "string",
            },
          },
        },
        with: [
          {
            op: "add",
            path: "/properties/q",
            value: { type: "number" },
          },
        ],
      },
    },
  },
});

fastify.post("/foo", {
  handler(req, reply) {
    reply.send({ ok: 1 });
  },
  schema: {
    body: {
      $merge: {
        source: {
          type: "object",
          properties: {
            q: {
              type: "string",
            },
          },
        },
        with: {
          required: ["q"],
        },
      },
    },
  },
});

验证生成器

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

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

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

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

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

const fastify = require("fastify")();
const Ajv = require("ajv");
const ajv = new Ajv({
  // fastify 使用的默认参数(如果需要)
  removeAdditional: true,
  useDefaults: true,
  coerceTypes: true,
  nullable: true,
  // 任意其他参数
  // ...
});
fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
  return ajv.compile(schema);
});

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

使用其他验证工具

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

const Joi = require("@hapi/joi");

fastify.post(
  "/the/url",
  {
    schema: {
      body: Joi.object()
        .keys({
          hello: Joi.string().required(),
        })
        .required(),
    },
    validatorCompiler: ({ schema, method, url, httpPart }) => {
      return (data) => schema.validate(data);
    },
  },
  handler,
);
const yup = require("yup");
// 等同于前文 ajv 基本配置的 yup 的配置
const yupOptions = {
  strict: false,
  abortEarly: false, // 返回所有错误(译注:为 true 时出现首个错误后即返回)
  stripUnknown: true, // 移除额外属性
  recursive: true,
};
fastify.post(
  "/the/url",
  {
    schema: {
      body: yup.object({
        age: yup.number().integer().required(),
        sub: yup
          .object()
          .shape({
            name: yup.string().required(),
          })
          .required(),
      }),
    },
    validatorCompiler: ({ schema, method, url, httpPart }) => {
      return function (data) {
        // 当设置 strict = false 时, yup 的 `validateSync` 函数在验证成功后会返回经过转换的值,而失败时则会抛错。
        try {
          const result = schema.validateSync(data, yupOptions);
          return { value: result };
        } catch (e) {
          return { error: e };
        }
      };
    },
  },
  handler,
);
其他验证工具的验证信息

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

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

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

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

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

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

const errorHandler = (error, request, reply) => {
  const statusCode = error.statusCode;
  let response;

  const { validation, validationContext } = error;

  // 检验是否发生了验证错误
  if (validation) {
    response = {
      // validationContext 的值可能是 'body'、'params'、'headers' 或 'query'
      message: `A validation error occured when validating the ${validationContext}...`,
      // 验证工具返回的结果
      errors: validation,
    };
  } else {
    response = {
      message: "An error occurred...",
    };
  }

  // 其余代码。例如,记录错误日志。
  // ...

  reply.status(statusCode).send(response);
};

序列化

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

示例:

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

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

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

const schema = {
  response: {
    "2xx": {
      type: "object",
      properties: {
        value: { type: "string" },
        otherValue: { type: "boolean" },
      },
    },
    201: {
      // 对比写法
      value: { type: "string" },
    },
  },
};

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

序列化函数生成器

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

fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => {
  return (data) => JSON.stringify(data);
});

fastify.get("/user", {
  handler(req, reply) {
    reply.send({ id: 1, name: "Foo", image: "BIG IMAGE" });
  },
  schema: {
    response: {
      "2xx": {
        id: { type: "number" },
        name: { type: "string" },
      },
    },
  },
});

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

错误控制

当某个请求 schema 校验失败时Fastify 会自动返回一个包含校验结果的 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 选项。当出现 验证错误 时,请求的 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);
  }
});

schemaErrorFormatter

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

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

const fastify = Fastify({
  schemaErrorFormatter: (errors, dataVar) => {
    // ... 自定义的格式化逻辑
    return new Error(myErrorMessage);
  },
});

// 或
fastify.setSchemaErrorFormatter(function (errors, dataVar) {
  this.log.error({ err: errors }, "Validation failed");
  // ... 自定义的格式化逻辑
  return new Error(myErrorMessage);
});

你还可以使用 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}`));
  }
});

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

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

const fastify = Fastify({
  ajv: {
    customOptions: { jsonPointers: true },
    plugins: [require("ajv-errors")],
  },
});

const schema = {
  body: {
    type: "object",
    properties: {
      name: {
        type: "string",
        errorMessage: {
          type: "Bad name",
        },
      },
      age: {
        type: "number",
        errorMessage: {
          type: "Bad age", // 为除了必填外的所有限制
          min: "Too young", // 自定义错误信息
        },
      },
    },
    required: ["name", "age"],
    errorMessage: {
      required: {
        name: "Why no name!", // 为必填设置
        age: "Why no age!", // 错误信息
      },
    },
  },
};

fastify.post("/", { schema }, (request, reply) => {
  reply.send({
    hello: "world",
  });
});

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

const localize = require("ajv-i18n");

const fastify = Fastify();

const schema = {
  body: {
    type: "object",
    properties: {
      name: {
        type: "string",
      },
      age: {
        type: "number",
      },
    },
    required: ["name", "age"],
  },
};

fastify.setErrorHandler(function (error, request, reply) {
  if (error.validation) {
    localize.ru(error.validation);
    reply.status(400).send(error.validation);
    return;
  }
  reply.send(error);
});

JSON Schema 支持

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

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

示例

同一个 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/shared.json",
  type: "object",
  definitions: {
    foo: {
      type: "object",
      properties: {
        city: { type: "string" },
      },
    },
  },
});

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

资源