RailsのJSONカラムをちゃんとバリデーションする

## 目的
RailsでJSONカラムを利用するとき、その大抵はRDSの正規化を正しくやろうとすると煩雑になりすぎるためにとった手段であることが多い(少なくとも私は)。
そういう場合対外においてJSONの形式は煩雑になるのでまともにバリデーションができていなかったり、ドキュメンテーションが難しかったりした。

なので本番のJSONカラムに対してJSON Schemaを利用して「バリデーション」「ドキュメンテーション」の両方を実現していこうという話。

※ 基本的にRDSにおいてJSONカラムを利用するのは私は悪手だと考えています。その中でどうしても現状完全に正規化し切るのは難しいという前提でJSONカラムを選択せざるを得なかった時の対応だと思って読んでいただけるといいと思います。

## この記事のゴール
* RailsのJSONカラムに対してバリデーションが入っている
* JSONカラムに対してドキュメンテーションがされている(自動でドキュメントが更新される)
* 上記二つが連動することによって陳腐化しないドキュメンテーションになっている

## JSON Schemaについて
今回JSONに対してバリデーション・ドキュメンテーションを行うために JSON Schema を使っていく。
書き口がOpenAPIとほぼ同じなのでOpenAPIを書いたことがある人であればほぼ迷わず書けると思う(多分)

https://json-schema.org/

今回は該当のリポジトリに `doc/jsonschema/` というディレクトリを作成し、この中にJSONファイルを入れるようにしました。
JSONファイルはJSONカラムの形式ごとに分けて運用しています。

## JSONカラムにバリデーションをかける

今回はこちらのgemを利用させてもらいました。

https://github.com/voxpupuli/json-schema

Gemfileに `json-schema` を追加。 & `bundle install`
```Gemfile
# Gemfile
gem 'json-schema'
```

カスタムバリデーターの追加
(エラーメッセージの構築は今回そこまでできてないです。。とはいえ管理画面からでほぼ自分でセットアップするので一旦これでよしとしてます)
```app/validators/json_validator.rb
class JsonValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    JSON::Validator.validate!(options[:schema], value, strict: true)
  rescue JSON::Schema::ValidationError => e
    error_message = options[:message] || e.message
    record.errors.add(attribute, error_message)
  end
end
```

AutoLoaderのパスが入ってなければ追加(ディレクトリは各プロダクトごとにどうするかは整理してください)
```config/application.rb
module Hoge
  class Application < Rails::Application
    ...
    config.autoload_paths << Rails.root.join("app", "validators")
  end
end
```

Railsのモデルでバリデーションを追加
定数としてJSON Schemaを読み込んでvalidatesを追加。
```rb
class Hoge < ApplicationRecord
  JSON_SCHEMA = File.open("doc/jsonschema/hoge.json") do |f|
    JSON.load(f)
  end

  validates :hoge, json: { schema: JSON_SCHEMA }
end
```



## JSON Schemaのドキュメンテーション
方法はいろいろありますが以下の観点でサクッとできそうなものを選定してます(必要になったら載せ替えをすればいいと思っているので若干雑に選定してます)

* HTML形式で簡単にhostingできてドキュメントが読める(サーバー立てるほどでもなと思ってます)
* 入れ子構造などを含むJSON Schemaを表現できている

今回はこちらのライブラリを利用させていただきました。

https://github.com/coveooss/json-schema-for-humans

方法としてはGithub Actionからドキュメントを生成してホスティング用のS3にアップロードするようにしています。

##### ビルド環境の用意

実行にはPythonが必要になります。
今回は当時最新だった　`3.10.5` のバージョンを入れてます。(最終的にgithub actionでビルドするのでローカルで作らなくてもまぁ良さそう)

私がpyenv使っているので
```doc/jsonschema/.python-version
3.10.5
```

```doc/jsonschema/requirements.txt
json-schema-for-humans
```

##### アップロード用のS3バケットの用意
public readでS3を用意(必要最低限の設定にしているので必要に応じて設定は足してください)
```tf
resource "aws_s3_bucket" "json_schema_doc_hosting" {
  bucket = "{{bucket name}}"
  acl = "public-read"

  policy = <<POLICY
{
  "Id": "PolicyForApiDocBudget",
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::{{bucket name}}/*"
    }
  ]
}
  POLICY

  website {
    index_document = "index.html"
  }
}
```

##### github actionからS3にアプロードするためのroleを用意
```tf
data "aws_iam_policy_document" "github_actions_deployment_json_schema_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = ["arn:aws:iam::{{AWS account_id}}:oidc-provider/token.actions.githubusercontent.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"

      values = [
        "repo:{{your repo}}:*"
      ]
    }
  }
}

resource "aws_iam_role" "github_actions_deployment_for_json_schema" {
  name = "gha-iam-role-gha-deployment-json-schema"

  assume_role_policy = data.aws_iam_policy_document.github_actions_deployment_json_schema_assume_role_policy.json
}

resource "aws_iam_role_policy_attachment" "github_actions_deployment_for_json_schema" {
  role       = aws_iam_role.github_actions_deployment_for_json_schema.name
  policy_arn = aws_iam_policy.github_actions_deployment_for_json_schema.arn
}

data "aws_iam_policy_document" "github_actions_deployment_for_json_schema_policy" {
  version = "2012-10-17"

  statement {
    actions = [
      "s3:PutObject"
    ]

    resources = [
      aws_s3_bucket.json_schema_doc_hosting.arn,
      "${aws_s3_bucket.json_schema_doc_hosting.arn}/*"
    ]
  }
}

resource "aws_iam_policy" "github_actions_deployment_for_json_schema" {
  name = "gha-deployment-json-schema-policy"
  policy = data.aws_iam_policy_document.github_actions_deployment_for_json_schema_policy.json
}

resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  thumbprint_list = [data.tls_certificate.github_actions.certificates.0.sha1_fingerprint]
}

data "tls_certificate" "github_actions" {
  url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
```

##### github action で main にマージした時に S3 にアップロード
json-schema-for-humans は Python が必要になるのでインストールして実行

```yml
name: Deploy JsonSchema

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  deploy-json-schema:
    runs-on: ubuntu-18.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: 3.10.5
      - name: Install Dependencies
        working-directory: doc/jsonschema
        run: pip install -r requirements.txt
      - name: generate Document
        working-directory: doc/jsonschema
        run: |
          mkdir result
          generate-schema-doc json_schema.json result/
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-region: ap-northeast-1
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/gha-iam-role-gha-deployment-json-schema
          role-session-name: GitHubActions-${{ github.run_id }}
          role-duration-seconds: 900
      - name: upload
        working-directory: doc/jsonschema
        run: aws s3 cp ./result/ s3://{{your S3 domain}}/result/ --recursive
```