検証とシリアライゼーション
検証とシリアライゼーション
Fastifyはスキーマベースのアプローチを採用しており、必須ではありませんが、ルートの検証と出力のシリアライズにJSON Schemaを使用することを推奨します。Fastifyは内部的にスキーマを高性能な関数にコンパイルします。
検証は、コンテンツタイプパーサーのドキュメントに記載されているように、コンテンツタイプがapplication-jsonの場合にのみ実行されます。
このセクションのすべての例は、JSON Schema Draft 7仕様を使用しています。
⚠ セキュリティに関する注意
スキーマ定義はアプリケーションコードとして扱ってください。検証およびシリアライゼーション機能は、
new Function()を使用してコードを動的に評価しますが、これはユーザー提供のスキーマでは安全に使用できません。詳細については、Ajvとfast-json-stringifyを参照してください。Fastifyは
$asyncAjv機能をサポートしていますが、最初の検証戦略の一部として使用しないでください。このオプションはデータベースへのアクセスに使用され、検証プロセス中にデータベースを読み取ると、アプリケーションに対するサービス拒否攻撃につながる可能性があります。asyncタスクを実行する必要がある場合は、検証が完了した後、preHandlerなどのFastifyのフックを使用してください。
基本概念
検証とシリアライゼーションのタスクは、カスタマイズ可能な2つの異なるアクターによって処理されます。
- リクエストの検証にはAjv v8を使用します。
- レスポンス本文のシリアライズにはfast-json-stringifyを使用します。
これら2つの独立したエンティティは、.addSchema(schema)を介してFastifyのインスタンスに追加されたJSONスキーマのみを共有します。
共有スキーマの追加
addSchema APIを使用すると、Fastifyインスタンスに複数のスキーマを追加し、アプリケーションの複数の部分で再利用できます。通常どおり、このAPIはカプセル化されています。
共有スキーマは、JSON Schemaの$refキーワードを使用して再利用できます。以下は、参照の*仕組み*の概要です。
- myField: { $ref: '#foo'}は、現在のスキーマ内で- $id: '#foo'を持つフィールドを検索します。
- myField: { $ref: '#/definitions/foo'}は、現在のスキーマ内で- definitions.fooフィールドを検索します。
- myField: { $ref: 'http://url.com/sh.json#'}は、- $id: 'http://url.com/sh.json'で追加された共有スキーマを検索します。
- myField: { $ref: 'http://url.com/sh.json#/definitions/foo'}は、- $id: 'http://url.com/sh.json'で追加された共有スキーマを検索し、- definitions.fooフィールドを使用します。
- myField: { $ref: 'http://url.com/sh.json#foo'}は、- $id: 'http://url.com/sh.json'で追加された共有スキーマを検索し、その中で- $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`
fastify.addSchema({
  $id: 'commonSchema',
  type: 'object',
  properties: {
    hello: { type: 'string' }
  }
})
fastify.post('/', {
  handler () {},
  schema: {
    body: { $ref: 'commonSchema#' },
    headers: { $ref: 'commonSchema#' }
  }
})
共有スキーマの取得
バリデーターとシリアライザーがカスタマイズされている場合、アクターはFastifyによって制御されなくなるため、.addSchemaメソッドは役に立ちません。Fastifyインスタンスに追加されたスキーマにアクセスするには、.getSchemas()を使用します。
fastify.addSchema({
  $id: 'schemaId',
  type: 'object',
  properties: {
    hello: { type: 'string' }
  }
})
const mySchemas = fastify.getSchemas()
const mySchema = fastify.getSchema('schemaId')
通常どおり、関数getSchemasはカプセル化され、選択されたスコープで使用可能な共有スキーマを返します。
fastify.addSchema({ $id: 'one', my: 'hello' })
// will return only `one` schema
fastify.get('/', (request, reply) => { reply.send(fastify.getSchemas()) })
fastify.register((instance, opts, done) => {
  instance.addSchema({ $id: 'two', my: 'ciao' })
  // will return `one` and `two` schemas
  instance.get('/sub', (request, reply) => { reply.send(instance.getSchemas()) })
  instance.register((subinstance, opts, done) => {
    subinstance.addSchema({ $id: 'three', my: 'hola' })
    // will return `one`, `two` and `three`
    subinstance.get('/deep', (request, reply) => { reply.send(subinstance.getSchemas()) })
    done()
  })
  done()
})
検証
ルート検証は内部で高性能JSON SchemaバリデーターであるAjv v8に依存しています。入力の検証は非常に簡単です。ルートスキーマに必要なフィールドを追加するだけです。
サポートされている検証は以下のとおりです。
- body: POST、PUT、またはPATCHメソッドの場合、リクエストの本文を検証します。
- querystringまたは- query: クエリ文字列を検証します。
- params: ルートパラメータを検証します。
- headers: リクエストヘッダーを検証します。
すべての検証は、完全なJSON Schemaオブジェクト(typeプロパティが'object'で、パラメータを含む'properties'オブジェクトを持つ)にすることも、type属性とproperties属性を省略してパラメータをトップレベルにリストする簡単なバリエーションにすることもできます(以下の例を参照)。
ℹ 最新バージョンのAjv(v8)を使用する必要がある場合は、
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'] }, // or { 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)
bodyスキーマの場合、スキーマをcontentプロパティ内にネストすることにより、コンテンツタイプごとにスキーマを区別することもできます。スキーマ検証は、リクエストのContent-Typeヘッダーに基づいて適用されます。
fastify.post('/the/url', {
  schema: {
    body: {
      content: {
        'application/json': {
          schema: { type: 'object' }
        },
        'text/plain': {
          schema: { type: 'string' }
        }
        // Other content types will not be validated
      }
    }
  }
}, handler)
Ajvは、検証に合格し、後で正しい型のデータを使用するために、スキーマのtypeキーワードで指定された型に値を強制しようとします。
FastifyのAjvのデフォルト設定では、querystringの配列パラメータの強制がサポートされています。例
const opts = {
  schema: {
    querystring: {
      type: 'object',
      properties: {
        ids: {
          type: 'array',
          default: []
        },
      },
    }
  }
}
fastify.get('/', opts, (request, reply) => {
  reply.send({ params: request.query }) // echo the querystring
})
fastify.listen({ port: 3000 }, (err) => {
  if (err) throw err
})
curl -X GET "https://:3000/?ids=1
{"params":{"ids":["1"]}}
パラメータタイプ(body、querystring、params、headers)ごとにカスタムスキーマバリデーターを指定することもできます。
たとえば、次のコードは、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インスタンスで使用するプラグインのリストを提供できます。プラグインは、**Fastifyに同梱されているAjvバージョンと互換性がある**必要があります。
プラグインの形式を確認するには、
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は、本文、URLパラメータ、ヘッダー、およびクエリ文字列を検証する関数を返す関数です。デフォルトのvalidatorCompilerは、ajv検証インターフェースを実装する関数を返します。Fastifyは内部でこれを使用して検証を高速化します。
Fastifyのajvの基本設定は以下のとおりです。
{
  coerceTypes: 'array', // change data type of data to match type keyword
  useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
  removeAdditional: true, // remove additional properties if additionalProperties is set to false, see: https://ajv.js.org/guide/modifying-data.html#removing-additional-properties
  uriResolver: require('fast-uri'),
  addUsedSchema: false,
  // Explicitly set allErrors to `false`.
  // When set to `true`, a DoS attack is possible.
  allErrors: false
}
この基本設定は、Fastifyファクトリにajv.customOptionsを提供することで変更できます。
追加の設定オプションを変更または設定する場合は、独自のインスタンスを作成し、既存のインスタンスを次のようにオーバーライドする必要があります。
const fastify = require('fastify')()
const Ajv = require('ajv')
const ajv = new Ajv({
  removeAdditional: 'all',
  useDefaults: true,
  coerceTypes: 'array',
  // any other options
  // ...
})
fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
  return ajv.compile(schema)
})
**注:** バリデーターのカスタムインスタンス(Ajvであっても)を使用する場合は、Fastifyのデフォルトバリデーターは使用されなくなり、FastifyのaddSchemaメソッドは使用しているバリデーターを認識しないため、Fastifyではなくバリデーターにスキーマを追加する必要があります。
他の検証ライブラリの使用
setValidatorCompiler関数を使用すると、ajvをほぼすべてのJavaScript検証ライブラリ(joi、yupなど)またはカスタムライブラリに簡単に置き換えることができます。
const Joi = require('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')
// Validation options to match ajv's baseline options used in Fastify
const yupOptions = {
  strict: false,
  abortEarly: false, // return all errors
  stripUnknown: true, // remove additional properties
  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) {
      // with option strict = false, yup `validateSync` function returns the
      // coerced value if validation was successful, or throws if validation failed
      try {
        const result = schema.validateSync(data, yupOptions)
        return { value: result }
      } catch (e) {
        return { error: e }
      }
    }
  }
}, handler)
.statusCodeプロパティ
すべての検証エラーには、400に設定された.statusCodeプロパティが追加されます。これにより、デフォルトのエラーハンドラーがレスポンスのステータスコードを400に設定することが保証されます。
fastify.setErrorHandler(function (error, request, reply) {
  request.log.error(error, `This error has status code ${error.statusCode}`)
  reply.status(error.statusCode).send(error)
})
他の検証ライブラリを使用した検証メッセージ
Fastifyの検証エラーメッセージは、デフォルトの検証エンジンと緊密に結びついています。ajvから返されたエラーは、最終的に、人間が理解しやすいエラーメッセージを作成する役割を担うschemaErrorFormatter関数によって実行されます。ただし、schemaErrorFormatter関数はajvを念頭に置いて記述されています。そのため、他の検証ライブラリを使用すると、奇妙なエラーメッセージや不完全なエラーメッセージが表示される場合があります。
この問題を回避するには、主に2つのオプションがあります。
- 検証関数(カスタムschemaCompilerから返される)がajvと同じ構造と形式でエラーを返すようにします(ただし、検証エンジン間の違いにより、これは困難でトリッキーになる可能性があります)。
- または、カスタムerrorHandlerを使用して、「カスタム」検証エラーをインターセプトしてフォーマットします。
カスタムerrorHandlerの作成に役立つように、Fastifyはすべての検証エラーに2つのプロパティを追加します。
- validation: 検証関数(カスタム- schemaCompilerから返される)から返されたオブジェクトの- errorプロパティの内容
- validationContext: 検証エラーが発生した「コンテキスト」(本文、パラメータ、クエリ、ヘッダー)
検証エラーを処理するこのようなカスタムerrorHandlerの非常に人為的な例を以下に示します。
const errorHandler = (error, request, reply) => {
  const statusCode = error.statusCode
  let response
  const { validation, validationContext } = error
  // check if we have a validation error
  if (validation) {
    response = {
      // validationContext will be 'body' or 'params' or 'headers' or 'query'
      message: `A validation error occurred when validating the ${validationContext}...`,
      // this is the result of your validation library...
      errors: validation
    }
  } else {
    response = {
      message: 'An error occurred...'
    }
  }
  // any additional work here, eg. log error
  // ...
  reply.status(statusCode).send(response)
}
シリアライゼーション
通常、データはJSONとしてクライアントに送信されます。Fastifyには、これを支援する強力なツールであるfast-json-stringifyがあります。これは、ルートオプションに出力スキーマを提供した場合に使用されます。出力スキーマを使用すると、スループットが大幅に向上し、機密情報の偶発的な開示を防ぐことができるため、使用することをお勧めします。
例
const schema = {
  response: {
    200: {
      type: 'object',
      properties: {
        value: { type: 'string' },
        otherValue: { type: 'boolean' }
      }
    }
  }
}
fastify.post('/the/url', { schema }, handler)
ご覧のとおり、レスポンススキーマはステータスコードに基づいています。複数のステータスコードに同じスキーマを使用する場合は、たとえば'2xx'またはdefaultを使用できます。
const schema = {
  response: {
    default: {
      type: 'object',
      properties: {
        error: {
          type: 'boolean',
          default: true
        }
      }
    },
    '2xx': {
      type: 'object',
      properties: {
        value: { type: 'string' },
        otherValue: { type: 'boolean' }
      }
    },
    201: {
      // the contract syntax
      value: { type: 'string' }
    }
  }
}
fastify.post('/the/url', { schema }, handler)
コンテンツタイプごとに特定のレスポンススキーマを設定することもできます。例:
const schema = {
  response: {
    200: {
      description: 'Response schema that support different content types'
      content: {
        'application/json': {
          schema: {
            name: { type: 'string' },
            image: { type: 'string' },
            address: { type: 'string' }
          }
        },
        'application/vnd.v1+json': {
          schema: {
            type: 'array',
            items: { $ref: 'test' }
          }
        }
      }
    },
    '3xx': {
      content: {
        'application/vnd.v2+json': {
          schema: {
            fullName: { type: 'string' },
            phone: { type: 'string' }
          }
        }
      }
    },
    default: {
      content: {
        // */* is match-all content-type
        '*/*': {
          schema: {
            desc: { type: 'string' }
          }
        }
      }
    }
  }
}
fastify.post('/url', { schema }, handler)
シリアライザーコンパイラ
serializerCompilerは、入力オブジェクトから文字列を返す必要がある関数を返す関数です。レスポンスJSONスキーマを定義する場合、すべてのルートをシリアライズする関数を指定することで、デフォルトのシリアライゼーションメソッドを変更できます。
fastify.setSerializerCompiler(({ schema, method, url, httpStatus, contentType }) => {
  return data => JSON.stringify(data)
})
fastify.get('/user', {
  handler (req, reply) {
    reply.send({ id: 1, name: 'Foo', image: 'BIG IMAGE' })
  },
  schema: {
    response: {
      '2xx': {
        type: 'object',
        properties: {
          id: { type: 'number' },
          name: { type: 'string' }
        }
      }
    }
  }
})
コードの非常に特定の部分でカスタムシリアライザーが必要な場合は、reply.serializer(...) を使用して設定できます。
エラー処理
リクエストのスキーマ検証が失敗した場合、Fastify は自動的にステータス 400 レスポンスを返します。このレスポンスには、ペイロードにバリデーターの結果が含まれます。たとえば、ルートに次のスキーマがある場合
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 プロパティには、以下に示すように、生の validation 結果を含む Error オブジェクトが含まれます。
const fastify = Fastify()
fastify.post('/', { schema, attachValidation: true }, function (req, reply) {
  if (req.validationError) {
    // `req.validationError.validation` contains the raw validation error
    reply.code(400).send(req.validationError)
  }
})
schemaErrorFormatter
エラーを自分でフォーマットする場合は、Fastify をインスタンス化する際に、エラーを返す必要がある同期関数を schemaErrorFormatter オプションとして提供できます。コンテキスト関数は Fastify サーバーインスタンスになります。
errors は Fastify スキーマエラー FastifySchemaValidationError の配列です。dataVar は、スキーマの現在検証されている部分です。(params | body | querystring | headers)。
const fastify = Fastify({
  schemaErrorFormatter: (errors, dataVar) => {
    // ... my formatting logic
    return new Error(myErrorMessage)
  }
})
// or
fastify.setSchemaErrorFormatter(function (errors, dataVar) {
  this.log.error({ err: errors }, 'Validation failed')
  // ... my formatting logic
  return new Error(myErrorMessage)
})
setErrorHandler を使用して、次のような検証エラーのカスタムレスポンスを定義することもできます。
fastify.setErrorHandler(function (error, request, reply) {
  if (error.validation) {
     reply.status(422).send(new Error('validation failed'))
  }
})
スキーマでカスタムエラーレスポンスを簡単に、かつ迅速に作成したい場合は、ajv-errors をご覧ください。例の使い方を確認してください。
ajv-errorsのバージョン 1.0.1 をインストールしてください。それ以降のバージョンは AJV v6(Fastify v3 に同梱されているバージョン)と互換性がありません。
以下は、カスタム AJV オプションを提供することにより、スキーマの**各プロパティのカスタムエラーメッセージ**を追加する方法を示す例です。以下のスキーマのインラインコメントは、ケースごとに異なるエラーメッセージを表示するように構成する方法を説明しています。
const fastify = Fastify({
  ajv: {
    customOptions: {
      jsonPointers: true,
      // Warning: Enabling this option may lead to this security issue https://www.cvedetails.com/cve/CVE-2020-8192/
      allErrors: 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', // specify custom message for
          min: 'Too young' // all constraints except required
        }
      }
    },
    required: ['name', 'age'],
    errorMessage: {
      required: {
        name: 'Why no name!', // specify error message for when the
        age: 'Why no age!' // property is missing from input
      }
    }
  }
}
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 スキーマのサポート
JSON スキーマは、スキーマを最適化するためのユーティリティを提供します。これは、Fastify の共有スキーマと組み合わせて、すべてのスキーマを簡単に再利用できるようにします。
| ユースケース | バリデーター | シリアライザー | 
|---|---|---|
| $refから$idへ | ️️✔️ | ✔️ | 
| $refから/definitionsへ | ✔️ | ✔️ | 
| $refから共有スキーマ$idへ | ✔️ | ✔️ | 
| $refから共有スキーマ/definitionsへ | ✔️ | ✔️ | 
例
同じ JSON スキーマでの $ref から $id への使用
const refToId = {
  type: 'object',
  definitions: {
    foo: {
      $id: '#address',
      type: 'object',
      properties: {
        city: { type: 'string' }
      }
    }
  },
  properties: {
    home: { $ref: '#address' },
    work: { $ref: '#address' }
  }
}
同じ JSON スキーマでの $ref から /definitions への使用
const refToDefinitions = {
  type: 'object',
  definitions: {
    foo: {
      $id: '#address',
      type: 'object',
      properties: {
        city: { type: 'string' }
      }
    }
  },
  properties: {
    home: { $ref: '#/definitions/foo' },
    work: { $ref: '#/definitions/foo' }
  }
}
共有スキーマ $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' }
  }
}
共有スキーマ /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' }
  }
}
リソース
- JSON スキーマ
- JSON スキーマについて
- fast-json-stringify ドキュメント
- Ajv ドキュメント
- Ajv i18n
- Ajv カスタムエラー
- エラーファイルダンプを使用したコアメソッドによるカスタムエラー処理 例