チュートリアル
OpenAPI から JSON API ドキュメント、TypeScript 型、API Client を自動生成するには?Schema からコード生成まで
手書きドキュメント、手書き型、手書き fetch が別々に漂うコストは、エンドポイント自体より高い。OpenAPI Schema を契約にし、ドキュメントも型も Client も同じ JSON から伸ばす。
GET /invoices で高いのは、実装より隣の三つの賞味期限だ。人向け API ドキュメント、コンパイラ向け TypeScript 型、実行時の fetch 包み。今も Wiki に見本 JSON を貼り、types.ts に同じ欄を手で書き、api.ts で URL を繋ぐチームがある。OpenAPI Schema の価値は、これを機械が読める一つの契約に畳むことだ。あなたは openapi.json(または YAML)を保守する。ドキュメントサイト、型ファイル、Client はそこから生成される。欄を一度変えれば三箇所が動く。ツール入参は MCP と JSON Schema、モデルの最終答は 構造化出力。本稿は HTTP JSON API だけだ。Schema からドキュメント、型、Client。規範は OpenAPI 3.1。既定の生成器は openapi-typescript。
一份の Schema、三つの産物:ドキュメント、型、Client
目標を先に固定する。OpenAPI ドキュメントは別の Markdown ではない。同じ spec の描画だ。Scalar、Swagger UI、Redoc は読者に過ぎない。TypeScript 型は第二の手書き interface ではない。paths と components.schemas の投影だ。API Client は「もう一枚の axios 包み」ではない。operationId か経路から生まれ、要求と応答の型を帯びた関数だ。三つのうち二つが手書きなら、Schema は壁のポスターだ。
道具は後で選ぶ。契約が先だ。FastAPI、Nest、Spring、Go の注釈から spec を出しても、openapi.json を先に書いて実装を合わせてもよい。禁じるのは、先に Client を書き、呼び出し側の「感じ」でドキュメントを足すことだ。単一の真実がなく、CI はどちらが正しいか判断できない。Agent が REST を道具にするなら、この OpenAPI を読むべきだ。第二の MCP inputSchema を写してはならない。二つの契約はすぐに drift する。層は上の MCP 文を見よ。
OpenAPI 3.1 は components.schemas を JSON Schema 2020-12 に寄せる。可空は type: ["string", "null"] であり、3.0 の nullable: true ではない。「ドキュメントは任意、型は必須」が減る。JSONVue で見るオブジェクトは、生成器が食べるオブジェクトであるべきだ。書き出すときに方言へ変換してはならない。
| 産物 | 出所 | 手で書いてはいけないもの |
|---|---|---|
| JSON API ドキュメント | 同じ OpenAPI JSON を Scalar / Swagger UI で描く | Wiki の見本 JSON と期限切れ截図 |
| TypeScript 型 | openapi-typescript が paths / components を出す | spec と並行する interface Invoice |
| API Client / hooks | openapi-fetch、Orval、hey-api が呼び出し関数を出す | 手組み URL と as Invoice |
先に OpenAPI JSON を正しく書く:paths、components、operationId
生成器の上限は spec の質だ。最低限は openapi 版、info、paths、再利用モデルを components.schemas へ。各操作に安定した operationId(listInvoices、createInvoice)を付ける。Client はそれを関数名にする。経路が変わっても id を漂わせるな。要求と成功応答は application/json を宣言し、コンポーネントへ $ref する。各 path に欄表を複製するな。
失敗応答も契約に入れる。400 / 401 / 404 / 422 に JSON Schema を付ければ、Client は unknown ではなく読める連合型にできる。クエリと経路パラメータは parameters に書け。body に隠し、ドキュメントで口約束するな。Bearer や OIDC は securitySchemes に書く。生成 Client は token の置き場を知る。認証プロトコル自体は OAuth 2.1。REST でも token はヘッダであり、JSON 封筒ではない。
下が最小の請求 API だ。製品 spec ではないが、ドキュメント、型、Client の三本を同時に回せる。Invoice は一度だけ現れ、list と create がそれを参照する。まずローカルで JSON 整形して parse を確認し、Schema で $ref が切れていないか見る。
{
"openapi": "3.1.1",
"info": {
"title": "Invoices API",
"version": "2026.09.18"
},
"paths": {
"/invoices": {
"get": {
"operationId": "listInvoices",
"parameters": [
{
"name": "status",
"in": "query",
"schema": {
"type": "string",
"enum": ["draft", "paid", "void"]
}
}
],
"responses": {
"200": {
"description": "Invoice list",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["items"],
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/Invoice" }
}
}
}
}
}
}
}
},
"post": {
"operationId": "createInvoice",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/InvoiceDraft" }
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Invoice" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"InvoiceDraft": {
"type": "object",
"required": ["customerId", "amount"],
"properties": {
"customerId": { "type": "string" },
"amount": { "type": "integer", "minimum": 1 },
"note": { "type": ["string", "null"] }
}
},
"Invoice": {
"allOf": [
{ "$ref": "#/components/schemas/InvoiceDraft" },
{
"type": "object",
"required": ["id", "status"],
"properties": {
"id": { "type": "string" },
"status": {
"type": "string",
"enum": ["draft", "paid", "void"]
}
}
}
]
}
}
}
}
人が読む JSON API ドキュメントを生成する
正しいドキュメントサイトは、構築時に openapi.json を静的ディレクトリへコピーし、隣に読者を置く。Scalar、Swagger UI、Redoc は同じファイルを読む。「ドキュメント」を別の Markdown の山だと思い、CI で spec と二度突き合わせてはならない。人がドキュメントを直し、機械が spec を直すと、翌日に分岐する。
読者が示せるのは spec に書いたものだけだ。example がなければ複製できる見本がない。失敗応答がなければ失敗の説明がない。description が空ならページに経路だけが残る。ドキュメントを生成する前に契約を埋めよ。プレビューはブラウザで読者を開けばよい。spec 自体は JSONVue で構造を見る方が、巨大 YAML で切れた $ref を探すより速い。
ドキュメント URL を info.contact や内部ポータルに書いてよい。ただし製品向けの第二の欄表を保守するな。製品が見たい列挙と必須は、すでに Schema の enum / required にある。二枚の欄表は drift の定番入口だ。
TypeScript 型を生成する:openapi-typescript
型生成の既定は openapi-typescript で足りる。実行時ゼロ、OpenAPI 3.0 と 3.1 を食べ、paths と components を出す。コマンドは npx openapi-typescript openapi.json -o src/api/schema.d.ts。遠隔 spec も使える。本番 CI はリポジトリ内のファイルを釘付けし、変わる URL を打ちに行かせるな。
import type { paths, components } from "./schema";
type Invoice = components["schemas"]["Invoice"];
type ListOk =
paths["/invoices"]["get"]["responses"][200]["content"]["application/json"];
export type { Invoice, ListOk };
型は経路で索引する。新たに interface を宣言するな。paths["/invoices"]["get"]["responses"][200]["content"]["application/json"] が一覧の成功体だ。モデルを再利用するなら components["schemas"]["Invoice"]。経路が変われば、三つの Invoice を探す代わりに、呼び出し側でコンパイラが怒る。
型は実行時検証ではない。生成された .d.ts はブラウザに存在しない。回線は欠けた JSON をまだ運ぶ。コンパイル通過は、契約に沿って Client を書いたことだけを意味する。実行時の門が要るなら、Orval/Kubb で Zod を出すか、重要な応答を Schema 検証する。「TypeScript が緑」を唯一の検査にするな。
API Client を生成する:openapi-fetch、Orval、hey-api
型だけでよいなら openapi-fetch を添える。createClient<paths>({ baseUrl }) のあと client.GET("/invoices", { params: { query } })。本文、クエリ、経路パラメータは型で止まる。createInvoice の body を list に送れない。これが 2026 フロントの薄い既定だ。ファイルは少なく、fetch は自分で握り、生成物の倉庫はない。
import createClient from "openapi-fetch";
import type { paths } from "./schema";
const client = createClient({
baseUrl: "https://api.example.com",
headers: { Authorization: "Bearer …" }
});
const { data, error } = await client.GET("/invoices", {
params: { query: { status: "paid" } }
});
const created = await client.POST("/invoices", {
body: { customerId: "cus_1", amount: 1999, note: null }
});
React Query hooks、Zod、MSW mock が要るなら Orval を上げる。同じ spec から関数とフックが伸びる。すでに Query をデータ層にしている応用に合う。hey-api(@hey-api/openapi-ts)は SDK 関数とインターセプタだ。operationId をメソッド名にしたいが、数千ファイルは要らないチーム向け。OpenAPI Generator の typescript-axios はまだ動くが出力は重い。新規は第一選択にしなくてよい。
どの流水線でも、生成ファイルを手で直すな。振る舞いを変えるなら spec、生成器設定、または手書きの包み(タイムアウト、再試行、テナント頭)だ。生成物を手直しするのは契約を捨てることだ。token は Authorization ヘッダへ。JSON body に埋めるな。
| 道具 | 得られるもの | 選ぶとき |
|---|---|---|
| openapi-typescript + openapi-fetch | 型 + 薄い fetch Client、実行時はほぼゼロ | 既定の起点。キャッシュを自分で握るとき |
| Orval | 型 + 関数 + Query hooks + 任意の Zod/MSW | すでに TanStack Query を使い、mock を spec に従わせたい |
| hey-api / Kubb | SDK メソッド名、またはプラグイン多産物 | インターセプタ基盤、または operation ごとに一つのファイル |
CI で drift を止め、JSONVue で契約を見る
契約が変われば生成物を再実行する。CI では spec を検証(Spectral または Redocly)→ 生成器を走らせ → 生成ディレクトリを git diff --exit-code。diff があれば失敗させ、新しい型を提出させる。ノートで一度 codegen して忘れるな。バックエンドが欄を変え、フロントの型が先週のまま、というのがこの事故の標準形だ。
npx --yes openapi-typescript openapi.json -o src/api/schema.d.ts
npx --yes @redocly/cli lint openapi.json
git diff --exit-code -- src/api/schema.d.ts
結合では少なくとも三つの JSON を扱う。OpenAPI spec 自体、ある 200 の body、ある 422 の失敗体。それに生成器へ渡す薄い夹具を足す。役割が違う。「汎用検証」一つのファイルに混ぜるな。失敗見本を一つずつ残す。切れた $ref、欠けた operationId、応答 content に application/json がない、列挙だけ変わった二版 spec。
ブラウザで終えられる:JSON 整形で spec が parse できるか見、JSON Schema 検証で components.schemas と実 body を確かめ、JSON Diffで二版 OpenAPI や「モデル arguments 対下流 body」を比べる。データはこの機械を出ない。続き:MCP と JSON Schema、構造化出力、同じ欄表からツール外壳を生成すること。
よくある質問 FAQ
spec は YAML か JSON か?
生成器は両方を食べる。倉庫では YAML の方が人の diff に優しい。ブラウザと JSONVue には JSON を送れ。CI では源ファイルを一つに釘付けし、もう一方は構築産物だけにする。二つの源が互いを直すな。
型だけ生成し、Client は出さなくてよいか?
よい。むしろそうすべきことが多い。openapi-typescript は .d.ts だけを出す。既存の axios 包みは、これらの型を少しずつ食べ始められる。経路と body が型で囲まれてから openapi-fetch や Orval に替える。初日に三千ファイルを出すより制御しやすい。
新規は OpenAPI 3.0 か 3.1 か?
新しい契約は 3.1。JSON Schema 2020-12 に揃え、可空と const の方言を一層減らせる。既存 3.0 を無理に上げるな。生成器(openapi-typescript 7.x、Orval 8)が 3.1 を宣言してから迁せ。Swagger 2.0 は先に 3.x へ上げてから Client を語れ。
OpenAPI と MCP inputSchema を一份にできるか?
業務欄は一份の canonical JSON Schema を共有し、OpenAPI の requestBody と MCP の inputSchema を別々に生成できる。OpenAPI ファイルごとを MCP Client のツール一覧に渡すな。伝送、認証、メソッド名は同じ層ではない。一份の欄表、二枚の外壳の方が、複製より drift しにくい。
まとめと次の一歩
OpenAPI コード生成は一文で書ける。機械が読める Schema が、人が読む JSON API ドキュメント、コンパイル時 TypeScript 型、型付き Client を同時に伸ばす。どれか一份を手で書けば、契約は契約でなくなる。
載せる順:openapi.json が parse でき、operationId があり、失敗応答が揃う。次にドキュメント読者。それから型。最後に Client を選ぶ。spec、成功 body、失敗 body を JSONVue でこの機械に残す。ツール入参は MCP と JSON Schema。モデルの答は構造化出力。