JavaScript
JSON
testing
OpenAPI
clojure.spec

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

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/coreisValidを使います。

const person = {

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

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

> s.explain(personSpec, person);

- failed:
key lastName required
in: lastName
at: lastName
- failed:
key languages required
in: languages
at: languages

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

> 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`を使ってバリデーション用のファンクションをスペック化できます。

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

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

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

> 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ファンクション】, 【オプション】);

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

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

オブション
説明

count
要素の個数

minCount
要素の最小個数

maxCount
要素の最大個数

distinct
重複を許すか? (デフォルト: false)

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

> 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のもう一つ面白いところは、バリデーションと同時にスペックに沿ったデータの生成ができるところです。

(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スペック、ビルトインのスペックには、ジェネレータファンクションがプリセットされているので、ただちにデータの生成が可能です。

> 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を使えば一気にたくさんのデータを生成可能です。

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

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

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


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 を使って関連付けます。

components:

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

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

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

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

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

% 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でなんとかすることを考えています。

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