API 契約駆動で開発できるようにした

Open API 形式の API 仕様を基礎として開発できるような体制を整えた。

背景と問題点

前回記事では、frasyr をモノリスの API サーバーとして動かすための第一歩として、ひとまず plumber でラップしてみた。 当然 frasyr を API 経由で使えるようになったものの、一方で後に控える契約層やフロントの開発のことを考えると、依存の向きに致命的な課題があった。 というのも、前回は plumber をひとまず触ってみるのが目的だったので、frasyr を API 化できること、また API ドキュメントを Swagger UI で閲覧できることを確認して一区切りとしていた。 問題は API ドキュメントの確認のほうで、これをやりたいために前記事では plumber の getApiSpec() を利用しており、将来的には契約の実体としたい OpenAPI 形式の仕様ファイルの方がコードから生成されていた。 本来あるべき姿としては、モノリスの API は契約に準拠している必要があるが(下図左)、API から仕様を生成したのでは、依存の向きが逆になってしまっている(下図右)。 これでは「契約」という概念を持ち込んだ意味がない。

前回記事の時点では、契約のほうが API から生成されてしまっていた

前回記事の時点では、契約のほうが API から生成されてしまっていた

将来的には上図左の “Contract layer” を契約から生成したいので、次のステップとして下記 2 点に取り組むこととした:

  • Open API 形式の API 仕様ファイルを契約の SSOT とする
  • 前回作ったモノリスサービスがこの契約に違反しないような仕組みを確立する

やったこと

ざっくりこんな感じ:

  • API 仕様をバージョン管理
  • Contract test の導入
  • リクエスト・レスポンスの検証
  • API ドキュメントの配信

それぞれ簡単にメモしておく。

API 仕様をバージョン管理

Open API 形式の仕様ファイルを、フロント–バック間における契約の SSOT としたい。 これまでは plumber から生成される openapi.json.gitignore していたがこれをやめ、バージョン管理を始めた。

これから仕様を手で書いていくとなると JSON 形式だと少々厳しそうなので、ついでに YAML に変換した:

yq -p json -o yaml '.' schema/openapi.json > schema/openapi.yaml

Contract test の導入

frasyr の API サーバーを契約通りに実装できているかを確認するために Contract test を導入した。 テスト用ツールには当初 Claude に薦められるがままに Dredd を使いかけていたが、repo の方を見てみたら public archive になっていたので Schemathesis を使うことにした。 他の候補として一応 Prism も検討したが、一人で開発するので Prism の売りの一つであるモックサーバーはそんなに魅力的に映らなかった。

Contract test としてのコア機能はこんな感じ:

  • Examples: API 仕様の example に valid なリクエスト例を書いておくことで、正常系のリクエストが生成される
  • Fuzzing: API 仕様をもとに、正常系・異常系のリクエストを生成する

Examples はまぁいいとして、Fuzzin test がなかなかおもしろかった。 Schemathesis が生成した、仕様的に valid なリクエストに対して 4XX5XX が返った場合、テストが失敗してくれるので、エラーハンドリングが甘かったり、仕様記述が漏れていたりといったミスに気付ける。 また逆に、仕様的に invalid なリクエストに対して 2XX 系が返った場合にも、エラーハンドリングや仕様記述のミスに気づけるというわけ。

あとは Reproducible Failures という機能が地味に便利で、期待と異なるレスポンスが返った時に、再現用の curl リクエストを表示してくれた:

=================================== FAILURES ===================================
_________________________________ POST /v0/vpa _________________________________
1. Test Case ID: FZf6yc

- Server error

[500] Internal Server Error:

    `{"error":"Internal validation error"}`

Reproduce with:

    curl -X POST -H 'Content-Type: application/json' -d '{"data": {"caa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_caa.csv", "waa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_waa.csv", "maa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_maa.csv"}, "params": {"m": 0.5, "fc_year": [2015, 2016, 2017], "tf_year": [2015, 2016], "term_f": "max", "stat_tf": "mean", "pope": true, "tune": false, "p_init": 0.5}}' http://host.docker.internal:31502/v0/vpa
    
    st replay FZf6yc

これだけでも、サーバー側のログと照らし合わせることで問題箇所の特定が捗った。

ちなみに、contract test が参照すべき Open API 仕様はローカルファイルパスだけでなく URL 指定もできるので、provider–consumer 関係の複数チームでも運用できそう。 本プロジェクトではひとまず monorepo でやるのでローカルパス指定でいく。

Dev 環境および CI におけるテスト設定のポイント

Dev 環境

開発中は検証サイクルを早くしたいので、image build を介さず、ホストの R ランタイムで直接起動した plumber server に対してテストするようにした:

# Makefile

run:
	VALIDATE_RESPONSE=true \
    LOG_LEVEL=$(LOG_LEVEL) \
    OPENAPI_SPEC_PATH=../../schema/openapi.yaml \
    PLUMBER_PORT=$(PLUMBER_DEV_PORT) \
    Rscript run.R

test-local:
	docker run --rm \
	  -v $$(pwd)/../../schema:/schema \
	  -v $$(pwd)/../../schemathesis.toml:/workspace/schemathesis.toml \
	  -v $$(pwd)/../..:/workspace \
	  -w /workspace \
	  ghcr.io/schemathesis/schemathesis:stable \
	  run /schema/openapi.yaml \
	  --url http://host.docker.internal:$(PLUMBER_DEV_PORT) \
 	  --coverage-format html --coverage-report-html-path ./schema-coverage.html

本プロジェクトは現時点ではクラウド環境を作っておらず、compose で立ち上げるコンテナを暫定的に本番環境とみなしている。 このコンテナを使った動作確認も並行して行う機会があったので、dev 環境(ホストの R ランタイムで立ち上げた plumber サーバー)のポートは PLUMBER_DEV_PORT として別に割り当てた。

ちなみにコンテナ版の Schemathesis からホストのエンドポイントを叩くので、base URL には host.docker.internal の利用が必須。

CI

CI ではより本番環境に近い状態でテストしたいので、API サーバーはコンテナ版を使うようにした。 ただしデリバリー済みのイメージを使ってしまうと PR 時などの CI として意味がないので、ワークフロー内でイメージをビルドしている。

CI で利用しているテストコマンドは下記:

# Makefile

test:
	docker run --rm \
	  --network $(DOCKER_NETWORK) \
	  -v $$(pwd)/../../schema:/schema \
 	  -v $$(pwd)/../../schemathesis.toml:/workspace/schemathesis.toml \
	  -v $$(pwd)/../..:/workspace \
	  -w /workspace/.schemathesis \
	  ghcr.io/schemathesis/schemathesis:stable \
	  run /schema/openapi.yaml \
	  --url http://monolith:$(CONTAINER_PORT) \
	  --coverage-format html --coverage-report-html-path ./schema-coverage.html

dev 環境とパスがちょっと違うのは、レポート書き出し時に permission error になったのでディレクトリを掘って逃げた形跡。 あとは compose で立ち上げたサービスを叩くので docker network を使うようになっており、エンドポイントはサービス名 monolith で解決できる。

リクエスト・レスポンスの検証

リクエストの検証に関しては、Schemathesis Fuzzy test のおかげで突貫ながらもそこそこ進められた。 additionalProperties についてはやや迷ったのだが、今のところ BFF しか利用しない内部 API として作るので false にしておいた。

レスポンスの検証は dev 環境のみで有効になるようにした。 これはちょうど最近読んだ『データ指向プログラミング1』のプラクティスを参考にしている。

API ドキュメントの配信

せっかく API 仕様を管理していくので、API ドキュメントをどこかにホストしたい。 Scalar というのがよさそうだったのでこれを使うことにした。 全然真面目に選定していないが、この手のツールは Open API document を読ませるだけなので、いざ問題が生じたとしても乗り換えコストは小さいはず。 ホスト先はいつも使っている Netlify にした。

ドメインについては、下手に stock-assessment とかを当ててしまうと、方向転換を新規プロジェクトでやりたくなったときに不便なので、プロジェクト名からなるドメインにした: https://api-docs.yoshimoto-samonji.rindrics.com/#tag/vpa

ハマったところ

Sechemathesis が生成した QUERY method によるテスト失敗

Schemathesis が(今年の 6 月に出たばかりの)QUERY method を投げてくれるのには困った。

==================================== ERRORS ====================================
_________________________________ POST /v0/vpa _________________________________
Network Error

Connection failed

    Connection aborted. Remote end closed connection without response

Reproduce with:

    curl -X QUERY -H 'Content-Type: application/json' -d '{"data": {"caa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_caa.csv", "waa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_waa.csv", "maa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_maa.csv"}, "params": {"m": 0.5, "fc_year": [2015, 2016, 2017], "tf_year": [2015, 2016], "term_f": "max", "stat_tf": "mean", "pope": true, "tune": false, "p_init": 0.5}}' http://host.docker.internal:31502/v0/vpa

確かに繋がらない:

curl -v -X QUERY -H 'Content-Type: application/json' -d '{"data": {"caa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_caa.csv", "waa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_waa.csv", "maa_url": "https://raw.githubusercontent.com/ichimomo/frasyr/dev/data-raw/ex2_maa.csv"}, "params": {"m": 0.5, "fc_year": [2015, 2016, 2017], "tf_year": [2015, 2016], "term_f": "max", "stat_tf": "mean", "pope": true, "tune": false, "p_init": 0.5}}' http://localhost:31502/v0/vpa
* Host localhost:31502 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:31502...
* connect to ::1 port 31502 from ::1 port 51462 failed: Connection refused
*   Trying 127.0.0.1:31502...
* Connected to localhost (127.0.0.1) port 31502
> QUERY /v0/vpa HTTP/1.1
> Host: localhost:31502
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 435
> 
* upload completely sent off: 435 bytes
* Empty reply from server
* Closing connection
curl: (52) Empty reply from server

どうしようもなかったので、いったんは phases.coverage.unexpected-methods に空配列を指定して、仕様にないメソッドを使ったケース自体の生成を止めて対応した。 plumber 側にメソッドを追加する contrubution など、時間があったら検討するかも。

DNS 解決に時間のかかる URL

現時点の実装では、データは URL を指定して ネットワーク越しに取得するようになっている2。 この理由から、例えば年齢別漁獲尾数をロードするために使われるパラメータ caa_urlformat: uri になっているのだが、この情報をもとに Schemathesis は fuzzy test phase ではランダムな URL を生成してリクエストを投げてくる。 このランダムな URL の中に、まれに valid(ホストがいる)だが DNS 解決に時間がかかるものがあり(例えば A.aG)、そのせいで契約テストが失敗することがあった。

$ dig A.aG

; <<>> DiG 9.10.6 <<>> A.aG
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 30374
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;A.aG.				IN	A

;; Query time: 3003 msec
;; SERVER: 2404:1a8:7f01:a::3#53(2404:1a8:7f01:a::3)
;; WHEN: Mon Jul 13 03:16:29 JST 2026
;; MSG SIZE  rcvd: 33

テストを通すにはそのような URL の生成を抑制する方法も考えられたが、ドメインと無関係な知識(?)を持ち込むことになるのでやめた。 代わりに、DNS 解決に内部タイムアウトを設け、タイムアウトした場合には Cannot resolve hostname として 404 を返すようにした。

check_dns <- function(hostname) {
  result <- system(
    sprintf("timeout 2 nslookup %s >/dev/null 2>&1", shQuote(hostname)),
    intern = FALSE
  )
  # timeout returns 124 if timed out, nslookup returns 1 if not found
  if (result %in% c(1, 124)) {
    stop(vpa_error(sprintf("Cannot resolve hostname: %s", hostname), 404))
  }
}

条件つき nullability のパラメータ

frasyr の vpa() の引数のうち、nullability が条件付きのものがある。 frasyr の利用法ドキュメントを見ると、例えば abund などのチューニングに使う引数は tune: true のときだけ必須となる。 このあたりを API 仕様にきちんと書かないと、fuzzy test がパターンを網羅的に生成するので 5XX 地獄になる。 意図しない組み合わせで呼ばれると、正しい使い方を促すエラーが frasyr からちゃんと出ているのだが、API としては仕様違反となって 500 が返ってしまう。 直訳するならば JSON schema の dependentRequired を使ってできそうではあるが、例えば optional なオブジェクト型の withTune パラメータを定義したほうがシンプルかもしれない。 ひとまず、今回は時間がなかったので tunefalse しか受け付けないものとして逃げた。 これはチューニング VPA をサポートする issue として個別に対応したほうがいいだろう。

学んだこと

  • 一つのエンドポイントが多くのパラメータを受け取ると、contract test が大規模になりがち
  • Fuzzy test は強力。静的な生成物のよい使い方って感じ

Contract test にやや手こずったが、依存の向きを逆転させて無事に API 契約駆動で開発できるようになったので今回はここまで。

契約の生成(左)をやめて、API を契約に準拠させられるようになった(右)

契約の生成(左)をやめて、API を契約に準拠させられるようになった(右)

参考情報


  1. Yehonathan Sharvit (2023) “データ指向プログラミング” 株式会社クイープ (訳), 翔泳社 ↩︎

  2. 将来的にデータ配信サービスを導入する可能性があるため ↩︎