JSONのSpecをしっかり書く

## はじめに

APIの仕様をSwagger/OpenAPIで書くケースは増えてきました。これはAPI提供側と利用側の取り決めなので、これさえしっかり書いてメンテしていけば、独立して開発できるようになります。

というのはいささか理想が過ぎるようにも思えます。というのもSwagger/OpenAPIでは仕様、特にやりとりするJSONのデータの仕様を書ききるのは難しいためです。一応型としてきっちり定義できるように、スキーマ定義の部分は仕様が膨らんできていますが、その延長線上にはそれこそメンテ不可能な、過去人類が通ったスペックモンスターの規格を生み出してしまわれそうです。

もう少し上手いやり方は無いものでしょうか?

そこでJSON Specというものを作ってみました。どんなAPIでもサーバサイドでは、受け取るパラメータやJSONのバリデーションはしっかり書くはずです。これを利用して、APIのテストにも活用する方法をご紹介します。

## JSON Spec

ソース: https://github.com/kawasima/json-spec
ライブラリ: https://www.npmjs.com/org/json-spec

```js
const s = require('@json-spec/core');
const gen = require('@json-spec/core/gen');
const sp = require('@json-spec/spec-profiles');
const sb = require('@json-spec/spec-basic');

const personSpec = s.object({
  required: {
    firstName: sp.firstName({ size: 100, locale:"ja"}),
    lastName:  sp.lastName({ size: 100, locale: "ja" }),
    languages: s.array([
      "C", "C++", "Java"
    ], { distinct: true, maxCount: 3 })
  },
  optional: {
    birthDay:  sp.birthDay,
    postalCd:  sp.postalCode_JP
  }
});
```

こんな感じでスペックを定義します。firstNameやlastNameなどは、ビルトインのスペックです。

バリデーションは、`@json-spec/core`の`isValid`を使います。

```js
const person = {
  firstName: "ピカチュウ"
};
s.isValid(personSpec, person); // => return false
```

バリデーションの詳細は、`explain`を使うと取れます。

```js
> s.explain(personSpec, person);
 - failed: 
key lastName required
 in: lastName
 at: lastName
 - failed: 
key languages required
 in: languages
 at: languages
```

オブジェクトとしても取れます。

```js
> s.explainData(personSpec, person);
{ problems:
   [ { path: [Array],
       via: [],
       pred: [Function: pred],
       reason: 'key lastName required',
       val: undefined,
       in: [Array] },
     { path: [Array],
       via: [],
       pred: [Function: pred],
       reason: 'key languages required',
       val: undefined,
       in: [Array] } ],
  spec:
   ObjectSpec {
     gfn: undefined,
     name: null,
     required:
      { firstName: [AndSpec],
        lastName: [AndSpec],
        languages: [ArraySpec] },
     optional: { birthDay: [AndSpec], postalCd: [ScalarSpec] },
     keysPred: [Function: keysPred] },
  value: { firstName: 'ピカチュウ' } }
```

### スペックの定義

spec`を使ってバリデーション用のファンクションをスペック化できます。

```js
> const even = s.spec(x => x % 2 === 0);

> s.isValid(even, 2)
true
> s.isValid(even, 3)
false
```

またスペックを組み合わせて複雑なスペックを生成できます。

```js
> const intSpec = s.spec(x => typeof(x) === 'number' && isFinite(x) && Math.floor(x) === x)
undefined
> s.and(intSpec, even)
> s.or(intSpec, even)
```

ObjectとArrayには専用のスペック定義メソッドがあります。

```
s.array(【Arrayの要素が満たすべきPredicateファンクション】, 【オプション】);
```

```js
s.array(x => x % 2 === 0, { count: 3 });
```

オプションでは、以下のものが指定可能です。

|オブション|説明|
|:---|:---|
|count|要素の個数|
|minCount|要素の最小個数|
|maxCount|要素の最大個数|
|distinct|重複を許すか? (デフォルト: false)|

Objectのスペックでは、キーが必須か任意かで定義を分けます。

```js
> const objSpec = s.object({
  required: {
    a: x => typeof(x) === 'string'
  },
  optional: {
    b: x => typeof(x) === 'number'
  }
})

> s.isValid(objSpec, { a: 'hoge' })
true
> s.isValid(objSpec, { b: 1 })
false
> s.isValid(objSpec, { a: 'hoge', b: 1 })
true

```

### 設計

JSON Specはイチから開発したわけではなく、clojure.specのJavaScript移植です。Clojureは動的型付け言語であり、またシンプルなマップやリストを使ってデータ構造を表現する設計指針もあいまって、既存のファンクションを利用する側が、ドキュメントを良く読んで使わなきゃいけない問題がありました。通常の言語のアプローチだと、型を導入してなんとかしようとするわけですが、Clojureではこのclojure.specを使って実行可能な仕様書を導入することで、この問題の解決を図っています。

https://clojure.org/about/spec

clojure.specのもう一つ面白いところは、バリデーションと同時にスペックに沿ったデータの生成ができるところです。

```clj
(gen/generate (s/gen int?))
;;=> -959
(gen/generate (s/gen nil?))
;;=> nil
(gen/sample (s/gen string?))
;;=> ("" "" "" "" "8" "W" "" "G74SmCm" "K9sL9" "82vC")
(gen/sample (s/gen #{:club :diamond :heart :spade}))
;;=> (:heart :diamond :heart :heart :heart :diamond :spade :spade :spade :club)

(gen/sample (s/gen (s/cat :k keyword? :ns (s/+ number?))))
;;=> ((:D -2.0)
;;=>  (:q4/c 0.75 -1)
;;=>  (:*!3/? 0)
;;=>  (:+k_?.p*K.*o!d/*V -3)
;;=>  (:i -1 -1 0.5 -0.5 -4)
;;=>  (:?!/! 0.515625 -15 -8 0.5 0 0.75)
;;=>  (:vv_z2.A??!377.+z1*gR.D9+G.l9+.t9/L34p -1.4375 -29 0.75 -1.25)
;;=>  (:-.!pm8bS_+.Z2qB5cd.p.JI0?_2m.S8l.a_Xtu/+OM_34* -2.3125)
;;=>  (:Ci 6.0 -30 -3 1.0)
;;=>  (:s?cw*8.t+G.OS.xh_z2!.cF-b!PAQ_.E98H4_4lSo/?_m0T*7i 4.4375 -3.5 6.0 108 0.33203125 2 8 -0.517578125 -4))
```

https://clojure.org/guides/spec#_generators

### データの生成

clojure.specと同じくJSON Specにもデータの生成機能を実装してあります。スペックには、その仕様に沿ったデータを生成するジェネレータファンクションを設定でき、ObjectスペックやArrayスペック、ビルトインのスペックには、ジェネレータファンクションがプリセットされているので、ただちにデータの生成が可能です。

```js
> gen.generate(s.gen(personSpec))
{ firstName: '蓮', lastName: '高橋', languages: [ 'C', 'Java' ] }

> gen.generate(s.gen(personSpec))
{ firstName: '蒼空',
  lastName: '清水',
  languages: [],
  birthDay: 1983-06-13T15:00:00.000Z,
  postalCd: '8581401' }

```

`sample`を使えば一気にたくさんのデータを生成可能です。

```js
> gen.sample(s.gen(person), 5)
[ { firstName: '結衣',
    lastName: '鈴木',
    languages: [ 'C++' ],
    birthDay: 1977-03-05T15:00:00.000Z,
    postalCd: '6012240' },
  { firstName: '杏',
    lastName: '井上',
    languages: [],
    postalCd: '6807950' },
  { firstName: '颯太',
    lastName: '斎藤',
    languages: [],
    birthDay: 2015-11-08T15:00:00.000Z },
  { firstName: '莉子', lastName: '小林', languages: [ 'Java', 'C' ] },                                              
  { firstName: '太一',
    lastName: '林',
    languages: [ 'C++', 'Java', 'C' ],
    postalCd: '5175024',
    birthDay: 1901-07-27T15:00:00.000Z } ]
```

## Open APIとともに

JSON Specは、Open APIとともに用いることでその真価を発揮できると考えています。スペックに沿ったデータ生成ができるので、データを用意せずとも、Open APIの仕様を読み込んで、モックサーバやモッククライアントが出来上がるのです。

https://www.npmjs.com/package/@json-spec/openapi

### モッククライアントを使ったAPIサーバのテスト

OpenAPIで定義されているエンドポイントを、スペックに沿って一通り叩いてくれるコマンドが用意されています。

```sh
json-spec-client --openapi=【OpenAPI定義のYAMLまたはJSON】 --jsonspec=【JSON Specの定義】--base-url=【APIサーバのベースのURL】
```

JSON Specは次のように、スペックをexportしたファイルを読み込ませます。

```js:jsonspec.js
const s = require('@json-spec/core');
const gen = require('@json-spec/core/gen');
const sp = require('@json-spec/spec-profiles');
const sb = require('@json-spec/spec-basic');

module.exports = {
  Pet: s.object({
    required: {
      id: sb.posInt,
      name: sp.name({}),
      tag: sb.enum(['dog','cat','lion'])
    }
  })
}
```

そして、Open APIのコンポーネント定義で、`x-json-spec` を使って関連付けます。

```yaml
components:
  schemas:
    Pet:
      x-json-spec: Pet
      required:
        - id
        - name
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        tag:
          type: string

```

これで、実行すると以下のような結果を得ます。

```shell
% node bin/client.js --openapi=examples/petstore/petstore.yaml --jsonspec=examples/petstore/jsonspec.js --base-url=http://localhost:3000
✔ pass! GET /pets?limit=18
✔ pass! POST /pets?limit=28
✔ pass! GET /pets/E75bNb4h7p3637aEVU42Z6Ikc

```

APIサーバのCIに仕込んでおけば、意図せず仕様違反となる修正をしてしまったことを検出できるようになります。

### APIクライアント開発で使うモックサーバ

既存のAPIサーバがあって、それを使って新たにシステムを作るのがトラブル少なくよいことですが、それらを同時に作らなきゃいけないことも往々にしてあります。


```shell-session
json-spec-server --openapi=【OpenAPI定義のYAMLまたはJSON】 --jsonspec=【JSON Specの定義】--port=【モックサーバのListenするポート】
```

そんなときに、JSON Specがあれば、Open API仕様を読み込んでAPIサーバを自動的に立ててくれます。

```shell-session
% node bin/server.js --openapi=examples/petstore/petstore.yaml --jsonspec=examples/petstore/jsonspec.js &
started
%  curl http://localhost:3000/pets | jq
[
  {
    "id": 5,
    "name": "Dawson Pfeffer",
    "tag": "lion"
  },
  {
    "id": 14,
    "name": "Ashleigh Farrell DVM",
    "tag": "dog"
  },
  {
    "id": 26,
    "name": "Pietro Lockman",
    "tag": "cat"
  },
  {
    "id": 2,
    "name": "Josue McClure",
    "tag": "dog"
  },
  {
    "id": 11,
    "name": "Tomasa Prohaska",
    "tag": "lion"
  },
  {
    "id": 21,
    "name": "Daren Fisher",
    "tag": "lion"
  },
  {
    "id": 12,
    "name": "Damon Tremblay",
    "tag": "lion"
  }
]
```

## おわりに

今後はJSON Specから``#/components/schemas`を生成する機能を開発予定です。これでJSON Specを書くのとOpen APIでのコンポーネント定義を書くので重複感がなくなります。

当然ながらテストだけでなく、実際のAPIの処理に置いてバリデーションとしてJSON Specが使えますが、サーバサイドの言語がNode.jsに限られてしまうので、[GraalVMのPolyglot](https://www.graalvm.org/docs/graalvm-as-a-platform/embed/)でなんとかすることを考えています。

Open APIの仕様を書くのに疲れた方は、JSON Specとともに使うことで、そのありがたみをハッキリと再認識できると思いますので、ぜひお試しください。