JavaScript ベースの Vue.js プロジェクトのデータ構造を JSON Schema を使って守る

Vue.js で SPA を書く際、言語として JavaScript(ES) で書くか TypeScript で書くかは一つの悩みどころとなると思います。

私個人としては、 Vue.js プロジェクトでは多くの場合 TypeScript を、 Nuxt.js プロジェクトの場合は、 Nuxt.js がまだまだ TypeScript サポートが弱いことから、 JavaScript で書くようにしています。

感覚的なものですが、 Nuxt.js + JavaScript の組み合わせがやはり開発の生産性としては非常に高く、効率的な開発ができる半面、データ構造レベルはせめて堅牢に守りたいというモチベーションが出てくることも確かです。

特に私の場合、サーバーサイドを Node.js + TypeScript で書くことが多いこともあり、可能な限りエンティティの型情報くらいは再利用できればと考えることが多い状況です。

今回は、そんな「エンティティの型」レベルの最低限のデータの整合性担保を、 Vue.js の props カスタムバリデータと、 JSON ベースの特定の言語に依存しない IDL である JSON Schema で解決してみる手法をご紹介します。

なお、今回は Vue.js + TypeScript の話は全くしませんので、もしそちらが気になるかたは先週書いた「[Vuexによる状態管理を含む最高に快適な Vue.js + TypeScript の開発環境を目指す話 - Qiita](https://qiita.com/potato4d/items/c9c0c8e674f20c85948a)」をご覧ください。

## JSON Schema について

![logo.jpg](https://qiita-image-store.s3.amazonaws.com/0/75633/09fa1446-1c58-7a9a-9775-52b5babd6b88.jpeg)

https://json-schema.org/

JSON Schema は、 JSON ベースのデータを利用して、エンティティ構造に対して注釈とバリデーションをつけることができる規格となります。

JSON がベースとなっているため、どの言語でも標準機能の範囲で解釈しやすく、かつ各言語にバリデータが移植・実装されているため、 JSON Schema をメンテナンスするだけで、非常に簡単にデータのバリデーションを引き回せる点が魅力です。

そして何よりも、 JSON の範囲で作られた規格であるため、 JavaScript や TypeScript コードと JSON をベースにした相互変換性に優れているという点があります。

JavaScript プロジェクトで雑にデータを守る時は、楽にデータを作れることがとても重要です。必死になってようやく守れる程度だと、面倒になって誰もやりません。本当です。

というわけで、今回は JSON Schema の中身や解釈については本当の最低限だけを紹介し、明日から運用していけるところまでをご紹介したいと思います。

## 自動生成を活用し、最大限サボって TypeScript から JSON Schema を吐き出す

JSON Schema がどういうものかを体験するために、実際にバリデーションを作って試すところまでを進めながら体験してみましょう。

### サンプルレポジトリについて

例によって例のごとくサンプルレポジトリを公開しています。

https://github.com/magitek-telescope/vue-json-schema-adcal-sample

### 事前準備: Vue.js プロジェクトの作成

まずはじめに、今回使うサンプルプロジェクトを作ってしまいましょう。
今回は、 Vue CLI v3 で生成できる babel / eslint の default プロジェクトを使います。

```bash:terminal
$ vue create vue-json-schema-adcal-sample
$ yarn serve
```

プロジェクトが動くようにしておいた上で、ターミナルで作業ディレクトリに移動しておいてください。

## quicktype を利用して自動生成をする

プロジェクトを作ることができたので、早速型を定義して JSON Schema を定義してみます。今回は、自動生成のために quicktype というツールを利用することとします。

https://github.com/quicktype/quicktype

これは、データ型の柔軟な相互変換が可能なツールであり、今回は TypeScript の interface から JSON Schema を生成するために利用します。

### quicktype のインストール

早速、NPM からインストールを行います。

```bash:terminal
$ yarn add -D quicktype
$ yarn quicktype
yarn run v1.12.1
$ /tmp/vue-json-schema-adcal-sample/node_modules/.bin/quicktype

Synopsis

  $ quicktype [--lang LANG] [--out FILE] FILE|URL ...

  LANG ...
  cs|go|rs|cr|c++|objc|java|ts|js|flow|swift|kotlin|elm|schema|ruby|dart|py

```

すごい量のメッセージが表示されたインストール成功です。

### TypeScript 定義の作成と export

次に TypeScript の型定義から JSON Schema を吐き出します。

バックエンドが TypeScript の場合はその定義をそのまま持ってきても良いし、あるいはどこかしらで API Spec が立っているのであれば、その JSON データから TypeScript の型定義を生成し、更に変換をかけてやっても良いでしょう。

今回は元となるデータがないので、仮に id / screen_name / display_name / bio / avatar_url をもつユーザーのデータを定義する場合を想定します。

types ディレクトリを作成し、 `types/definitions.ts` に以下を記述します。

```ts:types/definitions.ts
export interface user {
  id: number
  screen_name: string
  display_name: string
  bio: string
  avatar_url: string
}
```

おそらくこんな形のデータが出来上がるのではないかと思います。これを quicktype で変換してみます。変換は、コマンド一発で完了します。

```bash:terminal
$ yarn quicktype types/definitions.ts -o src/schema/definitions.json --lang schema
yarn run v1.12.1
$ /tmp/vue-json-schema-adcal-sample/node_modules/.bin/quicktype types/definitions.ts -o src/schema/definitions.json --lang schema
✨  Done in 1.03s.
```

完了です。コードを見に行ってみます。 `src/schema/user.json` を見に行くと、以下と同じデータが出力されているはずです。

```src/schema/definitions.json
{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "definitions": {
        "user": {
            "title": "user",
            "type": "object",
            "properties": {
                "id": {
                    "type": "number",
                    "title": "id"
                },
                "screen_name": {
                    "type": "string",
                    "title": "screen_name"
                },
                "display_name": {
                    "type": "string",
                    "title": "display_name"
                },
                "bio": {
                    "type": "string",
                    "title": "bio"
                },
                "avatar_url": {
                    "type": "string",
                    "title": "avatar_url"
                }
            },
            "required": [
                "avatar_url",
                "bio",
                "display_name",
                "id",
                "screen_name"
            ]
        }
    }
}
```

ざっくりと何が書かれているかわかるのではないでしょうか。

ルートに `$schema` という項目がありますが、これは JSON Schema のバージョン情報であり、自動生成して使う分には特に意識せずに利用して OK な領域となります。

`definitions` という部分が一番メインの定義一覧となっており、今回は user というオブジェクトが定義されています。そして、 user は全ての項目が必須データであり、かつ id 以外は全てが string という情報が格納されています。

TypeScript の export interface に相当する部分が definitions となり、これが対応関係となって自動生成されます。

これで user というデータがどういう構造を持っているかの JSON Schema データができあがりました。

### バリデーション用のスキーマ作成

このままでも使えないことはないのですが、少し使い勝手が悪いので、 JSON Schema を少しだけ手書きします。 `src/schema/user.js` を以下のように作成してください。

```src/schema/user.js
import definitions from './definitions.json'

export default {
  $schema: definitions.$schema,
  definitions: definitions.definitions,
  properties: {
    user: {
      $ref: '#/definitions/user'
    },
    required: [
      'user'
    ]
  }
}
```

行っていることは簡単で、 $schema および defininitions を引き継いだ上で、新たに properties を定義しています。

properties は自動生成したコードでも出てきましたが、そのオブジェクトのプロパティの一覧となります。今回は、 user を定義し、その上で $ref という怪しげな定義がなされています。

$ref は、 JSON Schema の定義の中の他の階層への参照となっており、今回の場合は `definitions.user` の中身をそのまま持ってくるという意味になります。

つまり、 definitions.json を自動生成するたび、都合の良いデータがそれぞれのエンティティに反映されることとなります。

普段利用する時は、 src/schema/user.js を利用し、 `{ user: user }` なコードを与えてやるだけで OK となりました。

これでうまく使うための準備ができたので、一旦生成は終了とします。

## Vue.js から JSON Schema を利用する

やっと Vue.js の世界に戻ってきました。 Vue.js から JSON Schema を利用するために、 JSON Schema の JavaScript 実装を導入、続けて props のバリデータをガリガリ書いていきます。

### jsonschema のインストール

まずは導入からです。オフィシャルパッケージがあるので、導入してしまいます。 jsonschema パッケージは実行に利用するため、 typescript の感覚で devDependencies に入れないように注意してください。

```bash:terminal
$ yarn add jsonschema
```

### validator 用のユーティリティの作成

jsonschema を導入できたら、バリデーションを集約するためのコードを書きます。 src/schema/user.js などのそれぞれのスキーマ定義をバリデーションに反映するための、 src/schema/validator.js を作成します。

今回は user だけなので、以下のように書いてしまうと良さそうです。

```src/schema/validator.js
import { Validator } from 'jsonschema'
import user from './user'

const validator = new Validator

export function isUser(value) {
  return validator.validate({ user: value }, user).valid
}

export const UserProp = {
  type: Object,
  required: true,
  validator(value) {
    return isUser(value)
  }
}
```

jsonschema ライブラリは、 new をした上で .validate(data, schema) 形式を実行するだけの簡単な仕組みなので、このように数行で定義を書いてしまうことができます。

このカスタムバリデーションを毎回使い回すのは面倒であるため、ビジネスロジックで使う汎用的なバリデーション処理(isUser)と、 Vue コンポーネントで利用する props コード(UserProp)を両方定義しておくと円滑です。

### Vue コンポーネントからバリデータの呼び出し

実際に呼び出してみます。
`src/components/AppUser.vue` に、以下のようにコードを記述します。

```src/components/AppUser.vue
<template>
  <div>
    {{user.display_name}}(@{{user.screen_name}})
  </div>
</template>

<script>
import { UserProp } from '../schema/validator'

export default {
  props: {
    user: UserProp
  }
}
</script>
```

その上で、 src/App.vue から、わざと空の user を与えてやりましょう。

```src/App.vue
<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <AppUser :user="{}" />
  </div>
</template>

<script>
import AppUser from './components/AppUser.vue'

export default {
  name: 'app',
  components: {
    AppUser
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

<img width="995" alt="Screen Shot 2018-12-19 at 23.52.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/75633/a5a2ac56-58cc-83e4-f8b9-99b64f98b6b9.png">

無事怒られていることが確認できます。

### バリデーション内容の修正

バリデーションに引っかかったので、少しだけマッチするがまだ間違っているコードも与えてみます。

実際に開発しているとありそうな、数値型になっているべき id が文字列になっているだけのパターンです。

```src/App.vue
<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <AppUser :user="user" />
  </div>
</template>

<script>
import AppUser from './components/AppUser.vue'

export default {
  name: 'app',
  components: {
    AppUser
  },
  computed: {
    user() {
      return {
        id: 'a',
        screen_name: 'potato4d',
        display_name: 'potato4d',
        bio: 'foobar',
        avatar_url: 'https://potato4d.me/icon.png'
      }
    }
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

こうした場合も、きちんと指摘してくれます。

<img width="995" alt="Screen Shot 2018-12-19 at 23.55.03.png" src="https://qiita-image-store.s3.amazonaws.com/0/75633/e3e4f41a-15f8-29c9-d4cf-626fb7d6d863.png">

最後に、正しいデータにした場合……

```src/App.vue
<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <AppUser :user="user" />
  </div>
</template>

<script>
import AppUser from './components/AppUser.vue'

export default {
  name: 'app',
  components: {
    AppUser
  },
  computed: {
    user() {
      return {
        id: 1,
        screen_name: 'potato4d',
        display_name: 'potato4d',
        bio: 'foobar',
        avatar_url: 'https://potato4d.me/icon.png'
      }
    }
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

<img width="995" alt="Screen Shot 2018-12-19 at 23.56.39.png" src="https://qiita-image-store.s3.amazonaws.com/0/75633/b9b46d32-54dd-6ac3-2d81-80f9dd2d43fa.png">

無事バリデーションのチェックを通過することができました。ある程度性善説的な運用になっていますが、 warn を許容できない心を持っている限りは、平和な開発ができそうです。

## 実際のユースケースなど

ここまでで、一通り JSON Schema を使ったデータの守りかたをご紹介しました。

「正直、これじゃ守りきれないよ」という意見をもつかたも、勿論少なからずいると思います。ですが、 Vue.js プロジェクトの開発モチベーションは非常に多種多様です。

未来を捨ててでも JavaScript らしいプログラミングをバリバリに行って高い生産性のもと開発を行いたいときもあれば、十分に熟考した上で TypeScript を用いて堅牢なシステムを開発したいときもあるはずです。

Vue.js + JavaScript w/ JSON Schema は、そんなぶつかり合うモチベーションのちょうど中間に位置する、 JavaScript プロジェクトにおける現実的な選択肢の一つとなると思っています。何より、 Vue.js の思想である Progressive に非常にマッチしています。

すぐに利用を始めることができ、また、すぐにそれをやめることができます。

既存プロジェクトにとりあえず最低限のルールを敷きたいかた、 SPA 以外で使われている型定義の恩恵を少しでもうけたいかたなど、試しに触ってみてはいかがでしょうか。