検証とシリアライゼーション
検証とシリアライゼーション
Fastifyはスキーマベースのアプローチを採用しており、必須ではありませんが、ルートの検証と出力のシリアライズにJSON Schemaを使用することを推奨します。Fastifyは内部的にスキーマを高性能な関数にコンパイルします。
検証は、コンテンツタイプパーサーのドキュメントに記載されているように、コンテンツタイプがapplication-json
の場合にのみ実行されます。
このセクションのすべての例は、JSON Schema Draft 7仕様を使用しています。
⚠ セキュリティに関する注意
スキーマ定義はアプリケーションコードとして扱ってください。検証およびシリアライゼーション機能は、
new Function()
を使用してコードを動的に評価しますが、これはユーザー提供のスキーマでは安全に使用できません。詳細については、Ajvとfast-json-stringifyを参照してください。Fastifyは
$async
Ajv機能をサポートしていますが、最初の検証戦略の一部として使用しないでください。このオプションはデータベースへのアクセスに使用され、検証プロセス中にデータベースを読み取ると、アプリケーションに対するサービス拒否攻撃につながる可能性があります。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 "http://localhost: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 カスタムエラー
- エラーファイルダンプを使用したコアメソッドによるカスタムエラー処理 例