KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

サーバーサイドもバリデーションで楽しよう!

こんにちは🎄
プラットフォームチームの石黒です。

こちらは株式会社カケハシ x TypeScriptアドベントカレンダー2021 17日目の記事です。

今回はajvによるJSON Schemaを用いた入力値のバリデーションについてご紹介します。

ajvとは?

APIなどから渡された入力値のバリデーションツールとして、ajvを採用しています。
これは、JSON Schemaを指定するだけでコードを書かずともバリデーションを行ってくれるもので、JSON SchemaのほかにはJSON Type Definitionにも対応しているとのことです。
例えば、以下のようにUser型を定義します。

type User = {
  name: string
  age: number
  favorites?: string[]
}

このUser型をJSON Schemaで表現すると、以下のように記述できます。
additionalPropertiesをfalseにしているので、定義した3つ以外のプロパティが含まれるオブジェクトはバリデーションエラーとみなされます。

const schema: JSONSchemaType<User> = {
  type: 'object',
  properties: {
    name: {type: 'string'},
    age: {type: 'number'},
    favorites: {items: {type: 'string'}, type: 'array'}
  },
  required: ['name', 'age'],
  additionalProperties: false
}

これを用いてajvでバリデーションしてみます。
バリデーションが通れば、コンパイラは以降入力値をUser型として推論してくれます。

const validate = ajv.compile(schema)

const user: unknown = {
  name: 'たかし',
  age: 10,
}

if (!validate(user)) {
  console.log(user.name) // <- error: Object is of type 'unknown'
  console.log(validate.errors)
}
// User型として扱える
console.log(user.name)

バリデーションが簡単になったのは分かった、でももっと楽したい

これで、バリデーションをローコードで実現できるようになりました。
大変ありがたいことですが、怠惰な人間ですので、JSON Schemaを書くことすら億劫に感じてしまいます。
そこで、typescript-json-schemaを利用して、Typescriptの型からJSON Schemaに変換させることにしました。
先程のUser型に、メールアドレスを新しく入れてみましょう。また、名前の文字数制限などバリデーションチェックしたいことを指定したいと思います。
typescript-json-schemaではアノテーションでバリデーションの補完ができます。

type User = {

  /**
  * @minLength 1
  * @maxLength 50
  */
  name: string

  age: number

  /**
  * @TJS-format email
  */
  email: string

  favorites?: string[]
}

このように、コメントを付加することで追加のバリデーション要件を定義します。
この型定義からJSON Schemaに変換しますが、今回はCLIで実行しました(コード上で変換処理を実装することもできます)。

npx typescript-json-schema --required true --noExtraProps true sample.ts User > UserSchema.json

出力されたUserSchema.jsonが以下です。
先程定義したJSON Schemaの内容に加え、アノテーションで定義した文字長とメールアドレスフォーマットが含まれています。

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "additionalProperties": false,
    "properties": {
      "name": {"maxLength": 50, "minLength": 1, "type": "string"},
      "age": {"type": "number"},
      "email": {"format": "email", "type": "string"},
      "favorites": {"items": {"type": "string"}, "type": "array"}
    },
    "required": ["age", "email", "name"],
    "type": "object"
}

出力されたJSONファイルを読み込み、ajvでバリデーションしてみましょう。
スキーマオブジェクトを直接渡せないので、compileでなくvalidateメソッドを使います。
ただしこのvalidateメソッドはタイプガードしてくれなかったので、trueであればUser型とみなすようisValidメソッドを用意しました。
また、emailフォーマットを使いバリデーションしたいので、ajv-formatsをインポートしています。

import Ajv from 'ajv'
import addFormats from 'ajv-formats'

const isValid = <T>(schemaName: string, payload: any): payload is T => {
  try {
    const schema = require(`./${schemaName}.json`)

    const ajv = new Ajv()
    addFormats(ajv) // ajv-formats
    const valid = ajv.validate(schema, payload)

    if (!valid) {
      console.log(ajv.errorsText())
      return false
    }
    return true
  } catch (e) {
    console.log(e)
    return false
  }
}

type User = {
  name: string
  age: number
  email: string
  favorites?: string[]
}

const data: unknown = {
  name: 'たかし',
  email: 'takashi@example.com',
  age: 10
}

if (isValid<User>('UserSchema', data)) {
  // Userとして扱われる
  console.log(data.name)
} else {
  console.log('validation error')
}

バリデーションチェックの実装をisValid<User>('UserSchema', data)だけで完結させることができます。
これで、定型的なバリデーションチェックを最小限の記述に留め、ビジネスロジックの実装により集中できるようになりました。

まとめ

バリデーション実装の流れとしては、
- (1) 型を定義する
- (2) 型からスキーマを生成する by typescript-json-schema
- (3) スキーマからバリデーションする by ajv

となります。

(2)に関してはビルド前にスキーマを生成するスクリプトを自動で実行するようにしておく方式を採用したため、一度用意すれば意識する必要はなくなりました。
(3)についても同様で、バリデーション関数を用意しボイラープレートコードを共通化してしまえばajvの存在を意識することなくバリデーションを実装できます。

これでバリデーションの呪縛から解き放たれ、慣れ親しんでいるTypescriptの型定義にのみ集中することができるようになりました。

難点としては、2つのライブラリを駆使しているので力技感が否めないことと、スクリプトを組んでCIに組み込むなど導入工数がやや大きいところでしょうか。
しかしながら、バリデーションのための大量のif文から解放されること、チーム内でバリデーションの方針にブレが生じないことなど、メリットは充分にあると思います。

最後まで読んでいただきありがとうございました!少しでも参考になれば幸いです☺️