公開日
OpenAPI で backend と frontend の型をそろえる
Spring Boot の API と Next.js の静的サイトで、手書きの型が API とずれてビルドが止まった。仕様を openapi.json に書き出してコードとのずれをテストで検出し、frontend の型はそこから生成するようにした話。記事が 0 件でも静的 export を通す小技も。
はじめに
このサイトの backend は Spring Boot、frontend は Next.js の静的 export。frontend はビルド時に API から記事を取り、静的なページにする。
最初、frontend の型は手書きだった。AI(Claude Code)と壁打ちしながら骨組みを作った段階では、backend の API がまだなかったので、こうなるだろうという形で書いていた。実際の API につないだら、ビルドが型ガードで止まった。
ずれていたのは 2 点。手書きの型は記事に id があると思っていたが、backend は slug しか返さない。一覧の要素に本文(bodyMarkdown)があると思っていたが、backend の一覧は本文を含まない。
まず手で合わせる
1 回目の修正は、backend に合わせて手で直した。
Post型をやめ、backend のPostSummary(一覧)とPostDetail(詳細)に合わせて 2 つに分ける- 型ガードも一覧用と詳細用に分け、一覧に本文を要求しない
- 一覧の React の
keyをidからslugに変える - モックは詳細 API の形で持ち、一覧では
PostSummaryの形に絞る。日時は API と同じ UTC で書く
これで動くようにはなった。ただ、手で合わせただけだと、次に backend が変わったときにまた気づけないんだよな。
記事が 0 件でもビルドを通す
同じ PR で、もう 1 つ直したことがある。output: "export" では、動的ルートの generateStaticParams が 1 件以上返さないとビルドが失敗する。記事が 0 件の種別があると、それだけでビルドが落ちる。
対処は、0 件のときだけ __placeholder__ という slug を 1 件返し、そのページは notFound() で 404 にすること。一覧には出さない。0 件のときにしか使わないので、実在する記事との衝突もない。公開前のサイトは記事 0 本。この小技がないと、デプロイの練習すらできなかった。
仕様をファイルに書き出す
2 回目の修正は backend 側。springdoc-openapi を入れて、OpenAPI の仕様を backend/openapi/openapi.json としてリポジトリに置いた。
要点は、仕様を手で書かないこと。コードから生成し、生成結果とコミット済みのファイルを比較するテストを置いた。OpenApiSpecTests が MockMvc で /v3/api-docs を取り、ファイルと比べる。ずれていればテストが失敗するので、CI の ./mvnw verify で検出できる。API を変えたときは -Dopenapi.update=true を付けて同じテストを走らせると、ファイルが書き換わる。
生成する仕様の中身も少し調整した。
- DTO のプロパティは既定で required にする。null を型に含めたものだけ任意
PostKindは小文字の名前付き enum、日時はdate-time- 400 と 404 も記載する
/v3/api-docs と Swagger UI は既定で無効にし、dev プロファイルだけ有効にした。本番で仕様を公開するつもりはない。無効になっていることもちゃんとテストで確認している。
型を生成する
frontend は openapi-typescript で openapi.json から lib/api-schema.d.ts を生成する。コマンドは npm run gen:api。lib/types.ts は生成した型の別名だけになった。
import type { components } from "./api-schema";
type Schemas = components["schemas"];
export type PostKind = Schemas["PostKind"];
export type PostSummary = Schemas["PostSummary"];
export type PostDetail = Schemas["PostDetail"];
CI では生成し直して git diff --exit-code で比べる。コミット済みの型が古ければ失敗する。openapi.json が変わったときも frontend の CI が走るよう、paths フィルタにこのファイルを足した。
これで、backend の変更 → テストが openapi.json の更新を要求 → frontend の CI が型の再生成を要求、と連鎖する。どこかでずれが放置されることは、もうない。
AI との付き合い方
最初の手書きの型は、backend がない段階で AI と一緒に書いたもの。ありそうな形ではあるが、根拠がない。根拠のない型をそのまま使い続けていたのが、やっぱ問題だった。
手で合わせて終わりにするか、仕様をファイルに書き出してテストで比べる形にするかは、自分で決めた。後から変えにくいことではないので、まず手で直して動かし、次の PR で仕組みにした。2 つのコードベースの間の契約は、人じゃなくてテストが覚えておくものなんだよな。
次回は、Spring Boot を Lambda の SnapStart で動かし、Aurora DSQL につないだ話を書く予定。