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

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

※後日追記 YAMLを生成物でなく契約の実体にできたのは良い変更だったと思っているが、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. 将来的にデータ配信サービスを導入する可能性があるため ↩︎

更新履歴

  • 2026-07-14: ペイロードの設計ミスを自覚したのでふりかえり