Use Cases (更新: 2026/6/7)

OpenAPI入門:openapi.yamlで仕様を書き、Swagger UIで見て、型まで自動生成する

OpenAPIとSwaggerの違いから、openapi.yamlの書き方、Swagger UIでの閲覧、スキーマからのTypeScript型生成、スキーマ駆動開発までを、実際に動くコードで順に解説します。

OpenAPI入門:openapi.yamlで仕様を書き、Swagger UIで見て、型まで自動生成する

「APIの仕様、Slackに貼った表が正なんで」と言われて、震えたことがあります。

その表、3日前のものでした。フロント側はそれを見て実装していて、バックエンド側はとっくに項目名を変えていて、結合した瞬間に全部赤くなった。誰も嘘をついていないのに、噛み合わない。仕様が「人間の記憶と善意」で管理されていると、こういう事故が起きます。

OpenAPIは、その「正」を機械が読める1枚のファイルに固定する仕組みです。今日はこれを、専門用語をなるべくかみ砕きながら、openapi.yamlを1本書くところから、Swagger UIで見る、そこから型やクライアントを自動で吐かせるところまで、順番にやっていきます。

この記事の要点

  • OpenAPIはAPIの「機械も読める約束表」を書く規格、Swaggerはそれを書く・見る・検証する道具の名前。別物として覚えると混乱しない。
  • 仕様(openapi.yaml)を先に1本決めると、型・クライアント・ドキュメント・テストが全部そこから生える。これがスキーマ駆動開発。
  • Swagger UIは仕様を人が読める画面にする道具。でも見た目がきれいでも中身は壊れている、はよくある。だからCLIでの検証とテストはセットで持つ。
  • コード生成は便利だが、生成物は手で直さない。正本はopenapi.yaml、生成物は使い捨て。直す場所を間違えると次の生成で消える。
  • 2026年6月時点で仕様のlatestは3.2.0。ただし生成ツールとの相性で、この記事はopenapi: 3.1.0に固定する。

OpenAPIとSwagger、何が違うのか

最初にここでつまずく人が多いので、はっきりさせます。

OpenAPIは「規格」です。REST APIのエンドポイント、リクエストの形、レスポンスの形、認証、エラーを、機械が読めるYAML(やJSON)で表すルールブック。料理でいえば「レシピの書き方の決まり」です。

Swaggerは「道具の名前」です。そのレシピを書くエディタ、画面で読ませるビューア、形式チェックするバリデータ。昔は規格自体もSwaggerと呼ばれていた名残で、今でも混ざって使われます。料理でいえば「レシピを書くための文房具やノート」だと思ってください。

言葉正体たとえ
OpenAPIAPIを書く規格そのものレシピの書式ルール
openapi.yamlその規格で書いた1枚のファイル1品分のレシピ用紙
Swagger UI仕様を人が読める画面にするビューアレシピを清書した完成品
Swagger Editor仕様を書きながら検証するエディタレシピを書くノート

ここを分けて覚えると、「OpenAPI 入門」で調べているのにSwaggerの話が出てきて混乱する、という事故が減ります。

スキーマ駆動開発って、要するに何?

スキーマ駆動開発は、実装を書く前に「約束表」を先に決めるやり方です。スキーマというのは、ここでは「データの形の定義」くらいの意味で読んでください。

普通はこうなりがちです。バックエンドが先に作る、フロントが後から「で、レスポンスどんな形?」と聞く、口頭やSlackで伝わる、ズレる。

スキーマ駆動だと順番が逆になります。

  1. まずopenapi.yamlで「注文を作るAPIはこの形を受け取り、この形を返す」と決める
  2. その1枚から、フロント用のTypeScriptの型とクライアントを自動で吐く
  3. その1枚から、バックエンド用のサーバの骨組みも自動で吐く
  4. 両者は同じファイルを見ているので、ズレようがない

冒頭のSlack事故が起きないのは、見ている「正」が1つだからです。人間の記憶ではなく、ファイルが正。これが効きます。

まず動かす:最小のopenapi.yamlを書く

説明より動かしたほうが早いです。小さな注文APIの仕様を1本書いて、検証して、型まで吐かせてみましょう。Node.js 20以上を想定します。コード生成まで動かすなら、OpenAPI GeneratorはJava実行環境を使うのでJavaも入れてください。

まず土台を作ります。

mkdir openapi-demo
cd openapi-demo
npm init -y
npm install -D @openapitools/openapi-generator-cli js-yaml
mkdir specs generated scripts

package.jsonscriptsをこう整えます。あとで全部この名前で呼びます。

{
  "type": "module",
  "scripts": {
    "validate": "openapi-generator-cli validate -i specs/openapi.yaml",
    "lint": "node scripts/check-openapi-rules.mjs",
    "test:contract": "node --test scripts/contract.test.mjs",
    "gen:client": "openapi-generator-cli generate -i specs/openapi.yaml -g typescript-fetch -o generated/client --additional-properties=supportsES6=true,npmName=@example/orders-client,npmVersion=0.1.0",
    "gen:server": "openapi-generator-cli generate -i specs/openapi.yaml -g typescript-nestjs-server -o generated/server",
    "check": "npm run validate && npm run lint && npm run test:contract && npm run gen:client"
  },
  "devDependencies": {
    "@openapitools/openapi-generator-cli": "latest",
    "js-yaml": "latest"
  }
}

次が本体、specs/openapi.yamlです。長く見えますが、読むべき骨は3つだけ。paths(どんなURLがあるか)、responses(共通のエラー形)、schemas(データの形)。ここだけ追えば全体が見えます。

OpenAPI 3.1ではJSON Schemaとの整合が強くなっていて、「値が空でもいい」を書きたいときは古いnullable: trueではなくtype: [string, "null"]と書きます。地味だけど大事な変更点です。

openapi: 3.1.0
info:
  title: Orders API
  version: 0.1.0
  description: スキーマ駆動で作る注文受付APIのサンプル
servers:
  - url: https://api.example.com/v1
paths:
  /orders:
    post:
      operationId: createOrder
      summary: 注文を作る
      tags: [orders]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
            examples:
              minimum:
                value:
                  customerEmail: [email protected]
                  items:
                    - sku: SKU-001
                      quantity: 2
      responses:
        "201":
          description: 注文を作成した
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    get:
      operationId: listOrders
      summary: 注文一覧を返す
      tags: [orders]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: 注文の一覧
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Order"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  responses:
    BadRequest:
      description: リクエストが不正
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: トークンが無いか不正
  schemas:
    CreateOrderRequest:
      type: object
      additionalProperties: false
      required: [customerEmail, items]
      properties:
        customerEmail:
          type: string
          format: email
        note:
          type: [string, "null"]
          maxLength: 500
        items:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/OrderItemInput"
    OrderItemInput:
      type: object
      additionalProperties: false
      required: [sku, quantity]
      properties:
        sku:
          type: string
          minLength: 1
        quantity:
          type: integer
          minimum: 1
    Order:
      type: object
      additionalProperties: false
      required: [id, customerEmail, status, items, createdAt]
      properties:
        id:
          type: string
          format: uuid
        customerEmail:
          type: string
          format: email
        status:
          type: string
          enum: [pending, paid]
        note:
          type: [string, "null"]
        items:
          type: array
          items:
            $ref: "#/components/schemas/OrderItemInput"
        createdAt:
          type: string
          format: date-time
    ErrorResponse:
      type: object
      additionalProperties: false
      required: [code, message]
      properties:
        code:
          type: string
          enum: [invalid_request, unauthorized]
        message:
          type: string

書けたら、いきなり目で確認しないで、まず機械に通します。

npm run validate

Validation passed のように出れば、仕様として文法的に正しい状態です。ここまでで「正」となる1枚ができました。

Swagger UIで人が読める形にして確認する

openapi.yamlは機械向けの約束表なので、そのままだとチームに見せづらい。そこでSwagger UIの出番です。仕様を、エンドポイント一覧・パラメータ・サンプル付きの読める画面に変えてくれます。

手っ取り早いのは、ブラウザ版のSwagger Editoropenapi.yamlの中身を貼ること。左に書いた仕様が、右に整形されたドキュメントとして出ます。インストール不要で、書きながら検証もしてくれます。

ローカルで動かしたいなら、Swagger UIのコンテナを1行で立てて、自分の仕様を読ませる手もあります。

docker run -p 8080:8080 -e SWAGGER_JSON=/spec/openapi.yaml -v "$PWD/specs:/spec" swaggerapi/swagger-ui

http://localhost:8080 を開けば、/orders のPOSTとGET、リクエスト例、レスポンスの形が画面で読めます。

ここで1つ注意。Swagger UIで見た目が整っていても、それは中身が正しい証拠にはなりません。OpenAPI 3.1をきちんと扱えるのは今のところSwagger Editor Next(SwaggerEditor@5)側で、旧バージョンは3.1非対応のまま「レガシー」扱いになっています。だから「画面で見て安心」は半分だけ。残り半分は、次に書く機械チェックで埋めます。

スキーマから型とクライアントを自動生成する

スキーマ駆動のいちばんおいしいところがここです。openapi.yamlが1枚あれば、フロントが手で型を書く必要が消えます。

npm run gen:client

これでgenerated/clientに、TypeScriptの型と、APIを呼ぶ関数一式が吐かれます。さっきのscripts-g typescript-fetchを指定しているので、ブラウザ標準のFetch APIを使うクライアントになります。supportsES6=trueを渡しているのは、今どきのモジュール形式で出すためです。

生成された型を使うと、呼び出し側はこうなります(イメージ)。

import { OrdersApi, Configuration } from "@example/orders-client";

const api = new OrdersApi(
  new Configuration({ basePath: "https://api.example.com/v1", accessToken: "..." })
);

// createOrder の引数も戻り値も、仕様から生成された型で固定される
const order = await api.createOrder({
  createOrderRequest: {
    customerEmail: "[email protected]",
    items: [{ sku: "SKU-001", quantity: 2 }],
  },
});

console.log(order.status); // "pending" | "paid" 以外は型エラーになる

status"cancelled"を入れようとすると、コンパイル時点で赤くなります。仕様にpendingpaidしか書いていないからです。フロントとバックの認識ズレが、結合前の手元で潰せる。これがスキーマ駆動の効きどころです。

バックエンド側のひな形も同じ1枚から吐けます。

npm run gen:server

大事なルールを1つ。生成物は手で直さないでください。クライアントもサーバの骨組みも、正本はspecs/openapi.yamlです。生成コードを直接いじると、次にgen:clientを回した瞬間に修正がきれいさっぱり消えます。直すなら仕様を直して、再生成する。これが鉄則です。

見た目だけでは守れないので、機械チェックを足す

公式バリデータは「文法が正しいか」は見てくれます。でも運用で本当に困るのは、文法的には正しいけど中身が危ない、というやつです。書きかけのTODOが残っている、更新系APIなのに認証指定を忘れている、エラー形式がエンドポイントごとにバラバラ。こういうのは別で見ないと素通りします。

まず、薄いルールチェックを置きます。

// scripts/check-openapi-rules.mjs
import { readFileSync } from "node:fs";

const spec = readFileSync("specs/openapi.yaml", "utf8");

// 仕様には残ってほしくないパターン
const forbidden = [
  { pattern: /\bTBD\b|\bTODO\b|あとで|仮置き/i, reason: "書きかけのプレースホルダが残っている" },
  { pattern: /nullable:\s*true/, reason: "OpenAPI 3.1ではJSON Schemaのnull型で書く" },
  { pattern: /password|secret|clientSecret|apiKey/i, reason: "公開前に機密フィールドを確認すること" },
];

const errors = forbidden
  .filter((rule) => rule.pattern.test(spec))
  .map((rule) => `- ${rule.reason}`);

// スキーマは追加プロパティの可否を必ず明示する
if (!spec.includes("additionalProperties: false")) {
  errors.push("- スキーマで additionalProperties を決めていない");
}

if (errors.length > 0) {
  console.error("仕様レビュー失敗:\n" + errors.join("\n"));
  process.exit(1);
}

console.log("仕様レビュー OK");

次に、契約テストです。契約テストというのは、実装の中身を全部叩くテストではなく「外向きに約束した形が壊れていないか」だけを見るテストです。operationIdの重複、更新系の認証漏れ、4xxエラーの共通化、注文状態のenumを確認します。

// scripts/contract.test.mjs
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
import { load } from "js-yaml";

const doc = load(readFileSync("specs/openapi.yaml", "utf8"));
const httpMethods = new Set(["get", "put", "post", "delete", "patch", "options", "head", "trace"]);

function operations() {
  return Object.entries(doc.paths ?? {}).flatMap(([path, pathItem]) =>
    Object.entries(pathItem)
      .filter(([method]) => httpMethods.has(method))
      .map(([method, operation]) => ({ path, method, operation }))
  );
}

test("operationId は一意で camelCase", () => {
  const ids = operations().map(({ operation }) => operation.operationId);
  assert.equal(new Set(ids).size, ids.length);
  for (const id of ids) {
    assert.match(id, /^[a-z][A-Za-z0-9]*$/);
  }
});

test("更新系の操作には security が必須", () => {
  for (const { path, method, operation } of operations()) {
    if (["get", "head", "options"].includes(method)) continue;
    assert.ok(operation.security?.length, `${method.toUpperCase()} ${path} に security が無い`);
  }
});

test("4xx レスポンスは共通コンポーネントを再利用する", () => {
  for (const { path, method, operation } of operations()) {
    for (const [status, response] of Object.entries(operation.responses ?? {})) {
      if (!status.startsWith("4")) continue;
      assert.ok(response.$ref?.startsWith("#/components/responses/"), `${method.toUpperCase()} ${path} ${status}`);
    }
  }
});

test("注文状態の enum が実装の決定と一致する", () => {
  const status = doc.components.schemas.Order.properties.status;
  assert.deepEqual(status.enum, ["pending", "paid"]);
});

ここまでまとめて回します。

npm run check

検証 → ルールチェック → 契約テスト → クライアント生成、が一気に走ります。これをCI(自動実行の仕組み)に乗せておくと、仕様を壊す変更がマージ前に止まります。Swagger UIの「見た目OK」では拾えなかった半分を、ここで拾うわけです。

APIのテスト全般をもう少し体系立てて組みたい人は、Claude Code APIテスト自動化に踏み込んだ手順があります。実装側の流れと合わせて見たいならClaude Code API開発ガイド、公開schemaの安全確認はClaude Codeセキュリティベストプラクティスが参考になります。

僕がやらかした失敗3つ

正直に書きます。スキーマ駆動を最初に回したとき、僕は3回事故りました。

ひとつ目は、画面の項目名をそのまま仕様にしたこと。UIに「キャンセル」ボタンがあったので、何も考えずstatuscancelledを足しました。でもDBにもドメインモデルにもテストにもcancelledは無かった。フロントはそれを「使える状態」と信じて分岐を書き、結合で全部崩れました。実装のどこにも無いものは、約束表に書いてはいけません。

ふたつ目は、エラー形式を統一しなかったこと。あるエンドポイントは{ message }、別のは{ error: { code } }を返していた。フロントのcatchが2種類必要になって地獄を見ました。今はcomponents.responsesのエラーを先に固定して、全エンドポイントでそれを$refで使い回しています。

みっつ目は、生成したクライアントを手で直したこと。型の名前が気に入らなくて生成物を書き換えたら、翌日の再生成で全部消えました。当たり前なんですが、やってしまうんですよね。正本は仕様だけ、と体に叩き込みました。

よくある質問

Q. OpenAPIとSwaggerはどっちを覚えればいい? 両方です。ただし役割が違います。「規格=OpenAPI」「道具=Swagger(UIやEditor)」と分けて、まずはOpenAPIの書き方から入ると迷いません。

Q. 3.1.0と最新の3.2.0、どっちで書くべき? 2026年6月時点でlatestは3.2.0ですが、コード生成ツールやSwagger UIの対応が追いついていない場面があります。生成まで回すなら、この記事のように3.1.0に固定するのが無難です。新規でツール都合が無いなら最新を追ってもかまいません。

Q. Swagger UIで見た目が整っていれば仕様は正しい? いいえ。Swagger UIは表示する道具で、検証する道具ではありません。operationIdの重複、認証漏れ、互換性のないenum追加は画面に出ません。CLIのvalidateと契約テストをセットで持ってください。

Q. スキーマ駆動だと開発が遅くなりませんか? 最初に仕様を書く分、立ち上がりは少し遅いです。でも型・クライアント・サーバ骨組みが自動で生えるので、フロントとバックの手戻りが激減します。結合フェーズで一気に取り返せます。

Q. nullを表したいときはどう書く? OpenAPI 3.1ではnullable: trueは使わず、type: [string, "null"]のようにJSON Schemaのnull型で書きます。3.0の癖を持ち込むと生成コードで後から効いてくるので注意してください。

実際に試した結果

社内の小さな注文APIで、この順番(仕様を1本書く → 検証 → 型生成 → 契約テスト)を回してみました。

いちばん変わったのは、結合フェーズの空気です。前は「で、レスポンスの形どうなってます?」というSlackのやり取りが毎日飛んでいたのが、ゼロになりました。フロントは生成された型を見れば済むし、statusに存在しない値を入れれば手元で赤くなる。冒頭の「3日前の表が正」みたいな事故が、構造的に起きなくなったんです。

逆に、人間が最後まで悩んで決めたのは4つだけでした。公開していいフィールドはどれか、enumを将来どう広げるか、認証エラーの文言、そして内部情報がschemaに漏れていないか。ここは自動化できません。

OpenAPIは「きれいなドキュメントを作るための資料」ではなく、チーム全員が同じ1枚の約束を見て作業するための足場だと思うと、いちばんしっくりきました。まずはopenapi.yamlを1本、手元で書いてvalidateを通すところから始めてみてください。型が自動で生えてくる体験をすると、もう手書きの仕様表には戻れなくなります。

手元に型やレビュー用のテンプレートを置いて毎回の指示を省きたい人はClaudeCodeLabの教材一覧を、チームで既存APIの棚卸しから生成クライアント導入まで一緒に固めたい場合はClaude Code研修・導入相談をのぞいてみてください。

#OpenAPI #Swagger #スキーマ駆動開発 #API設計 #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。