TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

JSON Schema と Ajv によるオシャレな JavaScript 開発

業務で久しぶりに JSON Schema と Ajv を利用しました。
基本的な使い方は公式をはじめとしていろんなサイトで紹介されているので、応用的な例を紹介したいと思います。

そもそも JSON Schema とは?

JSON Schema(JSONスキーマ)は、Json Schema organization によって開発・保守されているスキーマ言語で、JSONデータの構造をJSONそのもので定義するためのもの。

引用:https://ja.wikipedia.org/wiki/JSON_Schema
公式サイト:https://json-schema.org/

Ajv とは?

JavaScript 用 JSON Schema 検証ライブラリ です。
公式サイト:https://ajv.js.org/

実装してみた

「複数の PIN のバリデーション定義」を実装してみます。
やりたかったこと/やったことは以下の通りです。

  1. エラーメッセージを任意のメッセージにしたい
    • ajv-i18n だと日本語化はできても任意のメッセージにはできないようなので、
    • ajv-errors を導入して errorMessage で任意のメッセージを定義しました
  2. 同じような項目( pin_1 と pin_2 )はパターンで指定したい
    • patternProperties を使用して正規表現で指定しました
  3. 同じ定義は一つにまとめたい
    • $ref を使用して良い感じにまとめました
  4. 数字だけを許可したいが、ゾロ目はエラーにしたい
    • pattern を使用して正規表現で指定しました
    • 【注意】否定の正規表現は not を使用すると簡単ですが nullundefined の扱いに注意です
      • 例では値が存在する時のみ処理されるように if-then を使用しています
// require("ajv") だとデフォルトの draft-07 になってしまうため、最新版の draft-2020-12 を指定する
const Ajv = require("ajv/dist/2020");

// ajv-errors を使用する場合は allErrors: true が必須
const ajv = new Ajv({ allErrors: true });  // (1)
require("ajv-errors")(ajv);  // (1)

const schema = {
  type: "object",
  patternProperties: {
    "^pin_.*$": { "$ref": "#/$defs/pin" }  // (2), (3)
  },
  "$defs": {  // (3)
    pin: {
      type: "string",
      allOf: [
        {
          pattern: "^[0-9]*$",  // (4)
          errorMessage: "半角数字でご入力ください"  // (1)
        },
        {
          if: { type: "string" },
          then: {
            not: { pattern: "^([0-9])\\1*$" },  // (4)
            errorMessage: "ゾロ目は入力できません"  // (1)
          }
        }
      ]
    }
  },
  required: [ "pin_1" ],
  errorMessage: {
    required: { "pin_1": "PIN1 は必須です" }  // (1)
  }
};

const validate = ajv.compile(schema);

const test = data => {
  console.log("------------------------------------");
  console.log("validate(data):", validate(data));
  console.log("data:", data);
  console.log("validate.errors:", validate.errors);
};

test({});                                      // テストケースA
test({ pin_1: undefined, pin_2: undefined });  // テストケースB
test({ pin_1: null, pin_2: null });            // テストケースC
test({ pin_1: "", pin_2: "" });                // テストケースD
test({ pin_1: "aaaa", pin_2: "bbbb" });        // テストケースE
test({ pin_1: "1111", pin_2: "2222" });        // テストケースF
test({ pin_1: "1357", pin_2: "2468" });        // テストケースG

実行結果

テストケースA:バリデーションNG. pin_1 が必須エラー
------------------------------------
validate(data): false
data: {}
validate.errors: [
  {
    instancePath: '',
    schemaPath: '#/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'PIN1 は必須です'
  }
]
テストケースB:バリデーションNG. pin_1 が必須エラー、かつ、 pin_1/pin_2 が型エラー
------------------------------------
validate(data): false
data: { pin_1: undefined, pin_2: undefined }
validate.errors: [
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/type',
    keyword: 'type',
    params: { type: 'string' },
    message: 'must be string'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/type',
    keyword: 'type',
    params: { type: 'string' },
    message: 'must be string'
  },
  {
    instancePath: '',
    schemaPath: '#/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'PIN1 は必須です'
  }
]
テストケースC:バリデーションNG. (必須エラーにはならない)、 pin_1/pin_2 が型エラー
------------------------------------
validate(data): false
data: { pin_1: null, pin_2: null }
validate.errors: [
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/type',
    keyword: 'type',
    params: { type: 'string' },
    message: 'must be string'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/type',
    keyword: 'type',
    params: { type: 'string' },
    message: 'must be string'
  }
]
テストケースD:バリデーションOK. (必須エラーにはならない)
------------------------------------
validate(data): true
data: { pin_1: '', pin_2: '' }
validate.errors: null
テストケースE:バリデーションNG. pin_1/pin_2 が文字種エラー
------------------------------------
validate(data): false
data: { pin_1: 'aaaa', pin_2: 'bbbb' }
validate.errors: [
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/allOf/0/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: '半角数字でご入力ください'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/allOf/0/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: '半角数字でご入力ください'
  }
]
テストケースF:バリデーションNG. pin_1/pin_2 がゾロ目エラー
------------------------------------
validate(data): false
data: { pin_1: '1111', pin_2: '2222' }
validate.errors: [
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/allOf/1/then/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'ゾロ目は入力できません'
  },
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/allOf/1/if',
    keyword: 'if',
    params: { failingKeyword: 'then' },
    message: 'must match "then" schema'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/allOf/1/then/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'ゾロ目は入力できません'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/allOf/1/if',
    keyword: 'if',
    params: { failingKeyword: 'then' },
    message: 'must match "then" schema'
  }
]
テストケースG:バリデーションOK.
------------------------------------
validate(data): true
data: { pin_1: '1357', pin_2: '2468' }
validate.errors: null

だいたい期待通りの結果になりましたが、値がない場合(テストケースA-D)は結果がそれぞれ異なります。

  • テストケースA:プロパティ(key-value)が存在しない
  • テストケースB:value が undefined
  • テストケースC:value が null
  • テストケースD:value が 空文字

ちなみに null を許容する場合は type: ["string", "null"] または nullable: true と指定できますが、プロパティとしては存在するため必須エラーにはなりません。
※型エラーが抑制されるだけになります。

上記の例に、値がない場合はすべて必須エラーになるように、独自処理を追加してみた

上記の例に独自処理を追加して、値がない場合はすべて必須エラーになるようにしてみます。
具体的には ajv-keywords を利用して JSON データからプロパティを除外するキーワード except を定義し、指定した条件時に除外されるようにしてみます。

// require("ajv") だとデフォルトの draft-07 になってしまうため、最新版の draft-2020-12 を指定する
const Ajv = require("ajv/dist/2020");
const { _ } = require("ajv/dist/compile/codegen");  // ★追加★

// ajv-errors を使用する場合は allErrors: true が必須
const ajv = new Ajv({ allErrors: true });
require("ajv-errors")(ajv);

// ★追加★ ここから
// 独自キーワードの追加 (補足1)
ajv.addKeyword({
  keyword: "except",
  schemaType: "boolean",
  code: cxt => {
    const { gen, schema, it } = cxt;
    const { parentData, parentDataProperty } = it;
    if (schema) {
      gen.code(_`delete ${parentData}[${parentDataProperty}]`);
    }
  },
});
// ★追加★ ここまで

// 追加処理にあわせてバリデーション定義も更新
const schema = {
  type: "object",
  allOf: [ // (補足2)
    {
      patternProperties: {
        "^pin_.*$": {
          if: { anyOf: [ { not: { type: "string" } }, { const: "" } ] },
          then: { except: true },  // ★追加★
          else: { "$ref": "#/$defs/pin" }
        }
      }
    }
  ],
  "$defs": {
    pin: {
      type: "string",
      allOf: [
        {
          pattern: "^[0-9]*$",
          errorMessage: "半角数字でご入力ください"
        },
        {
          not: { pattern: "^([0-9])\\1*$" },
          errorMessage: "ゾロ目は入力できません"
        }
      ]
    }
  },
  required: [ "pin_1" ],
  errorMessage: {
    required: { "pin_1": "PIN1 は必須です" }
  }
};

const validate = ajv.compile(schema);

const test = data => {
  console.log("------------------------------------");
  console.log("validate(data):", validate(data));
  console.log("data:", data);
  console.log("validate.errors:", validate.errors);
};

test({});                                      // テストケースA
test({ pin_1: undefined, pin_2: undefined });  // テストケースB
test({ pin_1: null, pin_2: null });            // テストケースC
test({ pin_1: "", pin_2: "" });                // テストケースD
test({ pin_1: "aaaa", pin_2: "bbbb" });        // テストケースE
test({ pin_1: "1111", pin_2: "2222" });        // テストケースF
test({ pin_1: "1357", pin_2: "2468" });        // テストケースG

実行結果

テストケースA:バリデーションNG. pin_1 が必須エラー
------------------------------------
validate(data): false
data: {}
validate.errors: [
  {
    instancePath: '',
    schemaPath: '#/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'PIN1 は必須です'
  }
]
テストケースB:バリデーションNG. pin_1 が必須エラー
------------------------------------
validate(data): false
data: {}
validate.errors: [
  {
    instancePath: '',
    schemaPath: '#/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'PIN1 は必須です'
  }
]
テストケースC:バリデーションNG. pin_1 が必須エラー
------------------------------------
validate(data): false
data: {}
validate.errors: [
  {
    instancePath: '',
    schemaPath: '#/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'PIN1 は必須です'
  }
]
テストケースD:バリデーションNG. pin_1 が必須エラー
------------------------------------
validate(data): false
data: {}
validate.errors: [
  {
    instancePath: '',
    schemaPath: '#/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'PIN1 は必須です'
  }
]
テストケースE:バリデーションNG. pin_1/pin_2 が文字種エラー
------------------------------------
validate(data): false
data: { pin_1: 'aaaa', pin_2: 'bbbb' }
validate.errors: [
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/allOf/0/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: '半角数字でご入力ください'
  },
  {
    instancePath: '/pin_1',
    schemaPath: '#/allOf/0/patternProperties/%5Epin_.*%24/if',
    keyword: 'if',
    params: { failingKeyword: 'else' },
    message: 'must match "else" schema'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/allOf/0/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: '半角数字でご入力ください'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/allOf/0/patternProperties/%5Epin_.*%24/if',
    keyword: 'if',
    params: { failingKeyword: 'else' },
    message: 'must match "else" schema'
  }
]
テストケースF:バリデーションNG. pin_1/pin_2 がゾロ目エラー
------------------------------------
validate(data): false
data: { pin_1: '1111', pin_2: '2222' }
validate.errors: [
  {
    instancePath: '/pin_1',
    schemaPath: '#/$defs/pin/allOf/1/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'ゾロ目は入力できません'
  },
  {
    instancePath: '/pin_1',
    schemaPath: '#/allOf/0/patternProperties/%5Epin_.*%24/if',
    keyword: 'if',
    params: { failingKeyword: 'else' },
    message: 'must match "else" schema'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/$defs/pin/allOf/1/errorMessage',
    keyword: 'errorMessage',
    params: { errors: [Array] },
    message: 'ゾロ目は入力できません'
  },
  {
    instancePath: '/pin_2',
    schemaPath: '#/allOf/0/patternProperties/%5Epin_.*%24/if',
    keyword: 'if',
    params: { failingKeyword: 'else' },
    message: 'must match "else" schema'
  }
]
テストケースG:バリデーションOK.
------------------------------------
validate(data): true
data: { pin_1: '1357', pin_2: '2468' }
validate.errors: null

これで期待通りの結果になりました。

補足

  1. 独自キーワード(ユーザ定義キーワード)の詳細については 公式サイト に解説があります

    • 実装方法はいくつかありますが、 CodeGen を利用する方法が推奨されています
  2. バリデーションが実行される前に JSON データを編集したい場合

    • 複合キーワード( compound keyword )、つまり allOfif-then-else などで実行する必要があります

感想

基本的なことは用意されているキーワードたちを組み合わせることで実現できますが、独自の処理をしたい時はちゃんと理解しないと難しい部分もありました。
ですが、一度作ってしまえば再利用できるので恩恵は大きいと思います。

佐藤 淳(サトウ ジュン)
開発のいろんなことやってます。飲み会好きですがお酒はあんまり飲めません。


シナジーマーケティング株式会社では一緒に働く仲間を募集しています。