🍿

JSON データ量産環境構築 スキーマ拡張編

2024/01/08に公開

1. はじめに

前回は VSCode で JSON にバリデーションがかかるようにし、スニペットによってデータを量産する足掛かりとなる環境を構築しました。
今回は、前回スキーマの例として取り上げがユーザー情報をさらに拡張しつつ、JSON スキーマの書き方を紹介します。

2. JSON スキーマを拡張

2.1. 文字列型(string)

2.1.1. 決まった選択肢のみ許可(enum)

enum を設定するとある一定の範囲からしか選択できないプロパティを定義できます。

enum
"bloodType": {
  "type": "string",
  "enum": [ "A", "B", "O", "AB" ],
  "description": "血液型"
}

2.1.2. 文字列長の制限(minLength, maxLength)

文字列長の最小値・最大値を設定できます。

minLength, maxLength
"faceIcon": {
  "type": "string",
  "minLength": 1,
  "maxLength": 10,
  "description": "絵文字・顔文字"
}

2.1.3. 文字列パターン制限(pattern)

正規表現に合致する文字列のみに制限できます。

pattern
"postalCode": {
  "type": "string",
  "pattern": "^[0-9]{3}-[0-9]{4}$",
  "description": "郵便番号"
},
"landlinePhone": {
  "type": "string",
  "pattern": "^\\d{2,4}-\\d{2,4}-\\d{4}$"
},
"cellPhone": {
  "type": "string",
  "pattern": "^(050|070|080|090)-\\d{4}-\\d{4}$"
}

2.1.4. 文字列パターン制限(format)

有名なパターンは format として予め定められている場合があります。

format
"userId": {
  "type": "string",
  "description": "ユーザーID",
  "format": "uuid",
  "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[8,9,a,b][0-9a-f]{3}-[0-9a-f]{12}$",
  "defaultSnippets": [
    {
      "label": "ランダムUUIDv4",
      "description": "ランダムなユーザーIDを生成します。",
      "body": "$UUID"
    }
  ]
},
"mailAddress": {
  "type": "string",
  "format": "email",
  "description": "メールアドレス"
},
"createdAt": {
  "type": "string",
  "format": "date-time",
  "description": "ユーザー登録日時",
  "defaultSnippets": [
    {
      "label": "現在日時",
      "body": "${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}T${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}${CURRENT_TIMEZONE_OFFSET}"
    }
  ]
}

"format": "uuid" については draft 2019-09 から使用可能。

Resource identifiers - string | JSON Schema

"uuid": New in draft 2019-09 A Universally Unique Identifier as defined by RFC 4122.

また、 format は基本的にアノテーションでしかありません。
VSCode では "format": "uuid" だけだとバリデーションが行われなかったので pattern も指定した方が良いです。

2.2. 数値型(number)

2.2.1. 整数値(integer)+範囲(minimum, maximum)

integer / minimum, maximum
"age": {
  "type": "integer",
  "description": "年齢",
  "minimum": 0,
  "maximum": 300
},
  • minimum, maximum: 以上、以下 (指定の値を含む)

2.2.2. 浮動小数点数値(number)+範囲(exclusiveMinimum, exclusiveMaximum, multipleOf)

number / exclusiveMinimum, exclusiveMaximum, multipleOf
"height": {
  "type": "number",
  "exclusiveMinimum": 0.0,
  "exclusiveMaximum": 3.0,
  "multipleOf" : 0.01,
  "description": "身長[m] (小数点第2位まで)"
},
"weight": {
  "type": "number",
  "exclusiveMinimum": 0.0,
  "exclusiveMaximum": 300.0,
  "multipleOf" : 0.1,
  "description": "体重[kg] (小数点第1位まで)"
},
  • exclusiveMinimum, exclusiveMaximum: 超、未満 (指定の値を含まない)
  • multipleOf: 倍数
    • ここでは 0.01 などを指定することで小数点以下の有効桁数を設定している。

2.3. 真偽値型(boolean)

boolean
"isSetupCompleted": {
  "type": "boolean",
  "description": "初期設定 (true: 済, false: 未)"
},

2.4. 配列型(array)

2.4.1. リスト (同種型配列)

リスト型
"alternativeMailAddress": {
  "type": "array",
  "items": {
    "type": "string",
    "format": "email"
  },
  "uniqueItems": true,
  "minItems": 1,
  "maxItems": 2,
  "description": "代替メールアドレス"
},
  • uniqueItems: 配列の各要素は重複不可
  • minItems, maxItems: 要素数の最小, 最大

2.4.2. タプル(固定長配列)

タプル型
"address": {
  "type": "array",
  "prefixItems": [
    {
      "type": "string",
      "enum": [
        "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県",
        "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県",
        "新潟県", "富山県", "石川県", "福井県",
        "山梨県", "長野県", "岐阜県", "静岡県", "愛知県",
        "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県",
        "鳥取県", "島根県", "岡山県", "広島県", "山口県",
        "徳島県", "香川県", "愛媛県", "高知県",
        "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県"
      ]
    },
    { "type": "string", "pattern": "(?=^(.+市|(.+市)?.+区|(.+郡)?.+町|(.+郡)?.+村)$)(?=^[^ -~。-゚]+$)" },
    { "type": "string", "pattern": "^[^ -~。-゚]+$" },
    { "type": "string", "pattern": "^[^ -~。-゚]*$" }
  ],
  "items": false,
  "description": "住所 [都道府県, 市区郡町村, 地名(字、丁目など), 建物名・部屋番号] (半角英数/半角記号/半角カタカナを含まない、建物名は空欄許容)"
},
  • prefixItems: 配列先頭からスキーマ指定
  • "items": false: 配列のスキーマ指定なし位置の追加要素を許容しない

2.5. 複雑な条件付け

2.5.1. 否定条件(not)

中のスキーマが有効でない時に有効となります。
以下の例では sample@example.com などのダミーっぽいメールアドレスを排除します。

not
"mailAddress": {
  "type": "string",
  "format": "email",
  "not": {
    "pattern": "(example|sample|test|hoge|fuga|piyo)\\."
  },
  "description": "メールアドレス",
},

2.5.2. 論理積(allOf)・サブスキーマ($defs)・スキーマ参照($ref)

allOf は複数のスキーマを全て満たす場合に有効なスキーマです。
スキーマ参照($ref)を合体させるときによく使います。

住所について全角文字を使う条件をサブスキーマにし allOf を使うことで、市区郡町村の条件がシンプルに書けるようになりました。

allOf $defs $ref
"address": {
  "type": "array",
  "prefixItems": [
    {
      ...
    },
    {
      "allOf": [
        { "type": "string", "pattern": "^(.+市|(.+市)?.+区|(.+郡)?.+町|(.+郡)?.+村)$" },
        { "$ref": "#/$defs/zenkaku" }
      ]
    },
    { "$ref": "#/$defs/zenkaku" },
    { "$ref": "#/$defs/zenkaku" }
  ],
  "items": false,
  "description": "住所 [都道府県, 市区郡町村, 地名(字、丁目など), 建物名・部屋番号] (半角英数/半角記号/半角カタカナを含まない、建物名は空欄許容)"
},

2.5.3. 論理和 (anyOf)

anyOf はどれか1つでも満たせば有効となるスキーマを構成します。
初期設定済か否かのフラグ値に null を許容するため anyOf を使うことにします。

boolean
"isSetupCompleted": {
  "anyOf": [
    { "type": "boolean" },
    { "type": "null" }
  ],
  "description": "初期設定 (true: 済, false: 未)"
},

2.5.4. 排他的論理和(oneOf)・固定値(const)

oneOf はどれか1つだけ満たす場合のみ有効なスキーマを構築します。
以下の例は連携サービス名と関連するURLの設定を強制するスキーマです。

oneOf
"externalServiceConnection": {
  "type": "array",
  "items": {
    "oneOf": [
      {
        "type": "object",
        "properties": {
          "service": { "const": "twitter" },
          "url": { "type": "string", "pattern": ".*twitter\\.com.*" }
        }
      },
      {
        "type": "object",
        "properties": {
          "service": { "const": "facebook" },
          "url": { "type": "string", "pattern": ".*facebook\\.com.*" }
        }
      },
      {
        "type": "object",
        "properties": {
          "service": { "const": "google" },
          "url": { "type": "string", "pattern": ".*google\\.com.*" }
        }
      },
      {
        "type": "object",
        "properties": {
          "service": { "const": "github" },
          "url": { "type": "string", "pattern": ".*github\\.com.*" }
        }
      }
    ]
  },
  "description": "外部サービス連携"
}

2.5.5. 条件付き必須(dependentRequired)

dependentRequired は、あるプロパティが存在する場合だけ、別のプロパティも必須にする条件を設定します。
以下のスキーマは身長・体重の片方のみもつデータを禁止します。

dependentRequired
"dependentRequired": {
  "height": [ "weight" ],
  "weight": [ "height" ]
},

3. まとめ

今回はスキーマの書き方を紹介しました。
最終的に以下のスキーマが出来上がりました。

schema/users.schema.json
schema/users.schema.json
{
  "$schema": "https://json-schema.org/draft-07/schema",
  "$id": "https://awesome-web-service.com/schema/users",
  "title": "ユーザー情報",
  "description": "Awesome Web Service におけるユーザーの情報を表します。",
  "type": "object",
  "properties": {
    "userId": {
      "type": "string",
      "description": "ユーザーID(UUIDv4)",
      "format": "uuid",
      "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[8,9,a,b][0-9a-f]{3}-[0-9a-f]{12}$",
      "defaultSnippets": [
        {
          "label": "ランダムUUIDv4",
          "description": "ランダムなユーザーIDを生成します。",
          "body": "$UUID"
        }
      ]
    },
    "name": {
      "type": "string",
      "description": "姓名",
      "pattern": "^[^ ]{1,7} [^ ]{1,7}$",
      "defaultSnippets": [
        {
          "label": "慈英尊 好今",
          "body": "慈英尊 好今"
        }
      ]
    },
    "faceIcon": {
      "type": "string",
      "minLength": 1,
      "maxLength": 10,
      "description": "絵文字・顔文字"
    },
    "age": {
      "type": "integer",
      "description": "年齢",
      "minimum": 0,
      "maximum": 300
    },
    "birthDay": {
      "type": "string",
      "description": "誕生日",
      "format": "date",
      "defaultSnippets": [
        {
          "label": "現在日付",
          "body": "$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE"
        }
      ]
    },
    "sex": {
      "type": "string",
      "enum": [ "male", "female", "others", "unknown" ],
      "description": "性別"
    },
    "bloodType": {
      "type": "string",
      "enum": [ "A", "B", "O", "AB" ],
      "description": "血液型"
    },
    "height": {
      "type": "number",
      "exclusiveMinimum": 0.0,
      "exclusiveMaximum": 3.0,
      "multipleOf" : 0.01,
      "description": "身長[m] (小数点第2位まで)"
    },
    "weight": {
      "type": "number",
      "exclusiveMinimum": 0.0,
      "exclusiveMaximum": 300.0,
      "multipleOf" : 0.1,
      "description": "体重[kg] (小数点第1位まで)"
    },
    "postalCode": {
      "type": "string",
      "pattern": "^[0-9]{3}-[0-9]{4}$",
      "description": "郵便番号"
    },
    "landlinePhone": {
      "type": "string",
      "pattern": "^\\d{2,4}-\\d{2,4}-\\d{4}$",
      "description": "固定電話番号"
    },
    "cellPhone": {
      "type": "string",
      "pattern": "^(050|070|080|090)-\\d{4}-\\d{4}$",
      "description": "携帯電話番号"
    },
    "mailAddress": {
      "type": "string",
      "format": "email",
      "not": {
        "pattern": "(example|sample|test|hoge|fuga|piyo)\\."
      },
      "description": "メールアドレス",
      "defaultSnippets": [
        { "label": "gmail", "body": "${1:sample}@gmail.com" },
        { "label": "outlook", "body": "${1:sample}@outlook.com" },
        { "label": "hotmail", "body": "${1:sample}@hotmail.com" },
        { "label": "yahoo", "body": "${1:sample}@yahoo.co.jp" },
        { "label": "icloud", "body": "${1:sample}@icloud.com" }
      ]
    },
    "alternativeMailAddress": {
      "type": "array",
      "items": {
        "type": "string",
        "format": "email"
      },
      "uniqueItems": true,
      "minItems": 1,
      "maxItems": 2,
      "description": "代替メールアドレス"
    },
    "address": {
      "type": "array",
      "prefixItems": [
        { "$ref": "#/$defs/todofuken" },
        {
          "allOf": [
            { "type": "string", "pattern": "^(.+市|(.+市)?.+区|(.+郡)?.+町|(.+郡)?.+村)$" },
            { "$ref": "#/$defs/zenkaku" }
          ]
        },
        { "$ref": "#/$defs/zenkaku" },
        { "$ref": "#/$defs/zenkaku" }
      ],
      "items": false,
      "description": "住所 [都道府県, 市区郡町村, 地名(字、丁目など), 建物名・部屋番号] (半角英数/半角記号/半角カタカナを含まない、建物名は空欄許容)"
    },
    "isSetupCompleted": {
      "anyOf": [
        { "type": "boolean" },
        { "type": "null" }
      ],
      "description": "初期設定 (true: 済, false: 未)"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time",
      "description": "ユーザー登録日時",
      "defaultSnippets": [
        {
          "label": "現在日時",
          "body": "${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}T${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}${CURRENT_TIMEZONE_OFFSET}"
        }
      ]
    },
    "externalServiceConnection": {
      "type": "array",
      "items": {
        "oneOf": [
          {
            "type": "object",
            "properties": {
              "service": { "const": "twitter" },
              "url": { "type": "string", "pattern": ".*twitter\\.com.*" }
            }
          },
          {
            "type": "object",
            "properties": {
              "service": { "const": "facebook" },
              "url": { "type": "string", "pattern": ".*facebook\\.com.*" }
            }
          },
          {
            "type": "object",
            "properties": {
              "service": { "const": "google" },
              "url": { "type": "string", "pattern": ".*google\\.com.*" }
            }
          },
          {
            "type": "object",
            "properties": {
              "service": { "const": "github" },
              "url": { "type": "string", "pattern": ".*github\\.com.*" }
            }
          }
        ]
      },
      "description": "外部サービス連携"
    }
  },
  "required": [ "userId", "name", "birthDay", "mailAddress" ],
  "dependentRequired": {
    "height": [ "weight" ],
    "weight": [ "height" ]
  },
  "additionalProperties": false,
  "propertyNames": {
    "pattern": "^[a-z]+([A-Z][a-z0-9]+)*$"
  },
  "$defs": {
    "todofuken": {
      "type": "string",
      "enum": [
        "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県",
        "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県",
        "新潟県", "富山県", "石川県", "福井県",
        "山梨県", "長野県", "岐阜県", "静岡県", "愛知県",
        "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県",
        "鳥取県", "島根県", "岡山県", "広島県", "山口県",
        "徳島県", "香川県", "愛媛県", "高知県",
        "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県"
      ]
    },
    "zenkaku": { "type": "string", "pattern": "^[^ -~。-゚]*$" }
  },
  "defaultSnippets": [
    {
      "label": "テンプレート",
      "description": "データ作成用のテンプレートデータです。",
      "body": {
        "userId": "$UUID",
        "name": "慈英尊 好今",
        "faceIcon": "🥳",
        "age": 30,
        "birthDay": "$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE",
        "sex": "male",
        "bloodType": "AB",
        "height": 1.75,
        "weight": 70.5,
        "postalCode": "100-8111",
        "landlinePhone": "03-1111-2222",
        "cellPhone": "090-1111-2222",
        "mailAddress": "sample01@gmail.com",
        "alternativeMailAddress": ["sample02@gmail.com", "sample03@gmail.com"],
        "address": ["東京都", "千代田区", "千代田1−1", "ロイヤルシティ千代田101号室"],
        "isSetupCompleted": true,
        "createdAt": "${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}T${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}${CURRENT_TIMEZONE_OFFSET}",
        "externalServiceConnection": [
          { "service": "twitter", "url": "https://twitter.com/hoge" },
          { "service": "facebook", "url": "https://facebook.com/hoge" },
          { "service": "google", "url": "https://www.google.com/hoge" },
          { "service": "github", "url": "https://github.com/hoge" }
        ]
      }
    }
  ]
}

4. おまけ

ChatGPT に JSON スキーマに従ってダミーデータを生成してもらいました。

ChatGPTがJSONを生成

メールアドレスと住所はルールに従っていないことが、JSON スキーマの警告ですぐにわかります。

ChatGPTが生成したJSONの警告

以上

Discussion