API契約駆動で開発できるようにした
Open API形式のAPI仕様を基礎として開発できるような体制を整えた。
背景と問題点
前回記事では、frasyrをモノリスのAPIサーバーとして動かすための第一歩として、ひとまずplumberでラップしてみた。
当然frasyrをAPI経由で使えるようになったものの、一方で後に控える契約層やフロントの開発のことを考えると、依存の向きに致命的な課題があった。
というのも、前回はplumberをひとまず触ってみるのが目的だったので、frasyrをAPI化できること、またAPIドキュメントをSwagger UIで閲覧できることを確認して一区切りとしていた。
問題はAPIドキュメントの確認のほうで、これをやりたいために前記事ではplumberの getApiSpec() を利用しており、将来的には契約の実体としたいOpenAPI形式の仕様ファイルの方がコードから生成されていた。
本来あるべき姿としては、モノリスの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なリクエストに対して 4XX や 5XX が返った場合、テストが失敗してくれるので、エラーハンドリングが甘かったり、仕様記述が漏れていたりといったミスに気付ける。
また逆に、仕様的に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_url は format: 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 パラメータを定義したほうがシンプルかもしれない。
ひとまず、今回は時間がなかったので tune が false しか受け付けないものとして逃げた。
これはチューニングVPAをサポートするissueとして個別に対応したほうがいいだろう。
学んだこと
- 一つのエンドポイントが多くのパラメータを受け取ると、contract testが大規模になりがち
- Fuzzy testは強力。静的な生成物のよい使い方って感じ
Contract testにやや手こずったが、依存の向きを逆転させて無事にAPI契約駆動で開発できるようになったので今回はここまで。

契約の生成(左)をやめて、APIを契約に準拠させられるようになった(右)
参考情報
- Contract Testing | Martin Fowler
- OpenAPI Specification 3.2.0
- Scalar
- Schemathesis
- VPA関数を使った資源量推定 | ichimomo/frasyr
-
Yehonathan Sharvit (2023) “データ指向プログラミング” 株式会社クイープ (訳), 翔泳社 ↩︎
-
将来的にデータ配信サービスを導入する可能性があるため ↩︎
更新履歴
- 2026-07-14: ペイロードの設計ミスを自覚したのでふりかえり