Tips & Tricks

Claude Code で REST API を爆速設計・実装・テスト|OpenAPI仕様書から本番まで

Claude CodeでREST APIをOpenAPI仕様書から本番実装まで一気通貫で開発する方法を解説。Hono/Express/Fastify対応、zod バリデーション、vitest テスト自動生成まで実践コード付き。

なぜ REST API 設計に Claude Code が向いているのか

REST API 開発は「決まりきったパターンの繰り返し」が多い。エンドポイント定義、バリデーション、エラーハンドリング、テスト——これらを一つひとつ手で書いていると、本来考えるべきビジネスロジックに集中できない。

私(Masa)はDX推進エンジニアとして、社内業務システムのAPI刷新プロジェクトを担当している。以前は1エンドポイントあたり「設計30分→実装2時間→テスト1時間」が標準工数だったが、Claude Code を本格導入してからは 設計10分→実装30分→テスト15分 まで圧縮できた。

Claude Code が API 開発に強い3つの理由

  1. OpenAPI仕様書をコンテキストとして理解できる — YAMLを貼り付けるだけで、その仕様に準拠したコードを生成してくれる
  2. TypeScript の型システムを熟知している — 型安全なコードを最初から書いてくれるため、後から型エラーで詰まることが激減する
  3. テストパターンを大量に知っている — 正常系・異常系・エッジケースを網羅したテストを一括生成できる

1. OpenAPI仕様書を Claude Code で自動生成する

プロンプトの書き方

業務システムの「タスク管理API」を例に、OpenAPI仕様書を生成させる実際のプロンプトを紹介する。

以下の要件でOpenAPI 3.1仕様書(YAML)を生成してください。

リソース: Task(タスク管理)
フィールド:
  - id: UUID
  - title: string(1-200文字)
  - description: string(任意)
  - status: "todo" | "in_progress" | "done"
  - priority: "low" | "medium" | "high"(デフォルト: medium)
  - assignee_id: UUID(任意)
  - due_date: ISO8601形式(任意)
  - created_at: ISO8601形式
  - updated_at: ISO8601形式

エンドポイント:
  - GET /tasks(一覧、ページネーション付き)
  - GET /tasks/:id
  - POST /tasks
  - PUT /tasks/:id(全フィールド更新)
  - PATCH /tasks/:id(部分更新)
  - DELETE /tasks/:id

レスポンス形式: { data, meta, errors }
エラーコード: 400, 401, 403, 404, 422, 500
認証: Bearer Token(Authorization ヘッダー)

生成される OpenAPI YAML(抜粋)

openapi: 3.1.0
info:
  title: Task Management API
  version: 1.0.0
  description: タスク管理APIのOpenAPI仕様書

servers:
  - url: https://api.example.com/v1
    description: 本番環境
  - url: http://localhost:3000/v1
    description: 開発環境

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Task:
      type: object
      required: [id, title, status, priority, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
          nullable: true
        status:
          type: string
          enum: [todo, in_progress, done]
        priority:
          type: string
          enum: [low, medium, high]
          default: medium
        assignee_id:
          type: string
          format: uuid
          nullable: true
        due_date:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TaskCreate:
      type: object
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
        status:
          type: string
          enum: [todo, in_progress, done]
          default: todo
        priority:
          type: string
          enum: [low, medium, high]
          default: medium
        assignee_id:
          type: string
          format: uuid
        due_date:
          type: string
          format: date-time

    ApiResponse:
      type: object
      properties:
        data:
          type: object
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
        errors:
          type: array
          items:
            type: object
            properties:
              code:
                type: string
              message:
                type: string
              field:
                type: string

paths:
  /tasks:
    get:
      summary: タスク一覧取得
      security:
        - bearerAuth: []
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
        - name: status
          in: query
          schema:
            type: string
            enum: [todo, in_progress, done]
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Task'

この仕様書は後続のすべての工程(実装・テスト・ドキュメント)のインプットになる。


2. Hono でハンドラーを型安全に実装する

OpenAPI YAMLを生成したら、そのまま Claude Code に「この仕様書に従って Hono の実装を書いて」と依頼する。最近の業務では Hono を第一選択にしている。理由は Edge Runtime 対応・型安全・バンドルサイズが小さいからだ。

プロジェクト初期セットアップ

// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { taskRouter } from "./routes/tasks";

const app = new Hono();

// ミドルウェア設定
app.use("*", cors());
app.use("*", logger());
app.use("*", prettyJSON());

// ルーター登録
app.route("/v1/tasks", taskRouter);

// グローバルエラーハンドラー
app.onError((err, c) => {
  console.error(err);
  return c.json(
    {
      data: null,
      meta: null,
      errors: [{ code: "INTERNAL_ERROR", message: "サーバーエラーが発生しました" }],
    },
    500
  );
});

export default app;

タスクルーターの実装

// src/routes/tasks.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { TaskCreateSchema, TaskUpdateSchema, TaskPatchSchema, TaskQuerySchema } from "../schemas/task";
import { TaskService } from "../services/taskService";

export const taskRouter = new Hono();
const taskService = new TaskService();

// タスク一覧取得
taskRouter.get("/", zValidator("query", TaskQuerySchema), async (c) => {
  const query = c.req.valid("query");
  const result = await taskService.findAll(query);
  return c.json({
    data: result.tasks,
    meta: {
      total: result.total,
      page: query.page,
      per_page: query.per_page,
    },
    errors: null,
  });
});

// タスク詳細取得
taskRouter.get("/:id", async (c) => {
  const id = c.req.param("id");
  const task = await taskService.findById(id);

  if (!task) {
    return c.json(
      {
        data: null,
        meta: null,
        errors: [{ code: "NOT_FOUND", message: "タスクが見つかりません", field: "id" }],
      },
      404
    );
  }

  return c.json({ data: task, meta: null, errors: null });
});

// タスク作成
taskRouter.post("/", zValidator("json", TaskCreateSchema), async (c) => {
  const body = c.req.valid("json");
  const task = await taskService.create(body);
  return c.json({ data: task, meta: null, errors: null }, 201);
});

// タスク全更新
taskRouter.put("/:id", zValidator("json", TaskUpdateSchema), async (c) => {
  const id = c.req.param("id");
  const body = c.req.valid("json");
  const task = await taskService.update(id, body);

  if (!task) {
    return c.json(
      {
        data: null,
        meta: null,
        errors: [{ code: "NOT_FOUND", message: "タスクが見つかりません", field: "id" }],
      },
      404
    );
  }

  return c.json({ data: task, meta: null, errors: null });
});

// タスク部分更新
taskRouter.patch("/:id", zValidator("json", TaskPatchSchema), async (c) => {
  const id = c.req.param("id");
  const body = c.req.valid("json");
  const task = await taskService.patch(id, body);

  if (!task) {
    return c.json(
      {
        data: null,
        meta: null,
        errors: [{ code: "NOT_FOUND", message: "タスクが見つかりません", field: "id" }],
      },
      404
    );
  }

  return c.json({ data: task, meta: null, errors: null });
});

// タスク削除
taskRouter.delete("/:id", async (c) => {
  const id = c.req.param("id");
  const deleted = await taskService.delete(id);

  if (!deleted) {
    return c.json(
      {
        data: null,
        meta: null,
        errors: [{ code: "NOT_FOUND", message: "タスクが見つかりません", field: "id" }],
      },
      404
    );
  }

  return c.json({ data: null, meta: null, errors: null }, 204);
});

3. Zod バリデーションスキーマの自動生成

Claude Code に「OpenAPI の schemas セクションから Zod スキーマを生成して」と指示するだけで、型安全なバリデーションが出来上がる。

// src/schemas/task.ts
import { z } from "zod";

// タスクのステータス・優先度定義
const TaskStatus = z.enum(["todo", "in_progress", "done"]);
const TaskPriority = z.enum(["low", "medium", "high"]);

// 基底スキーマ(DBから返ってくる完全なタスク)
export const TaskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1, "タイトルは1文字以上").max(200, "タイトルは200文字以内"),
  description: z.string().nullable(),
  status: TaskStatus,
  priority: TaskPriority,
  assignee_id: z.string().uuid().nullable(),
  due_date: z.string().datetime().nullable(),
  created_at: z.string().datetime(),
  updated_at: z.string().datetime(),
});

// 作成リクエスト
export const TaskCreateSchema = z.object({
  title: z.string().min(1, "タイトルは1文字以上").max(200, "タイトルは200文字以内"),
  description: z.string().optional(),
  status: TaskStatus.default("todo"),
  priority: TaskPriority.default("medium"),
  assignee_id: z.string().uuid().optional(),
  due_date: z.string().datetime().optional(),
});

// 全更新リクエスト(全フィールド必須)
export const TaskUpdateSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().nullable(),
  status: TaskStatus,
  priority: TaskPriority,
  assignee_id: z.string().uuid().nullable(),
  due_date: z.string().datetime().nullable(),
});

// 部分更新リクエスト(すべて任意)
export const TaskPatchSchema = TaskUpdateSchema.partial();

// クエリパラメータ
export const TaskQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  per_page: z.coerce.number().int().min(1).max(100).default(20),
  status: TaskStatus.optional(),
  priority: TaskPriority.optional(),
  assignee_id: z.string().uuid().optional(),
});

// TypeScript型エクスポート
export type Task = z.infer<typeof TaskSchema>;
export type TaskCreate = z.infer<typeof TaskCreateSchema>;
export type TaskUpdate = z.infer<typeof TaskUpdateSchema>;
export type TaskPatch = z.infer<typeof TaskPatchSchema>;
export type TaskQuery = z.infer<typeof TaskQuerySchema>;

before/after 比較

項目Claude Code 導入前Claude Code 導入後
スキーマ定義の工数1エンドポイントあたり30〜45分5〜10分(プロンプト記述含む)
型エラー発生率プロジェクト後半で多発ほぼゼロ(最初から型整合)
バリデーション漏れレビューで毎回指摘自動でエッジケースを網羅
ドキュメントとの乖離2〜3週間で古くなる仕様書から生成するため常に一致

4. テストコードの自動生成(vitest)

プロンプト例

上記の tasks ルーターに対して vitest + supertest を使ったテストを書いて。
以下を必ず含めること:
- 正常系: 一覧取得、詳細取得、作成、更新、削除
- 異常系: 存在しないID、バリデーションエラー(各フィールド)
- 認証エラー: トークンなし、無効なトークン
- エッジケース: ページネーション境界値、最大文字数

生成されるテストコード(抜粋)

// src/routes/tasks.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import app from "../index";
import { TaskService } from "../services/taskService";

// TaskService をモック化
vi.mock("../services/taskService");

const mockTask = {
  id: "550e8400-e29b-41d4-a716-446655440000",
  title: "テストタスク",
  description: "テスト用のタスクです",
  status: "todo" as const,
  priority: "medium" as const,
  assignee_id: null,
  due_date: null,
  created_at: "2026-04-25T00:00:00.000Z",
  updated_at: "2026-04-25T00:00:00.000Z",
};

describe("GET /v1/tasks", () => {
  it("正常系: タスク一覧を返す", async () => {
    vi.mocked(TaskService.prototype.findAll).mockResolvedValue({
      tasks: [mockTask],
      total: 1,
    });

    const res = await app.request("/v1/tasks", {
      headers: { Authorization: "Bearer valid-token" },
    });

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.data).toHaveLength(1);
    expect(body.meta.total).toBe(1);
    expect(body.errors).toBeNull();
  });

  it("異常系: ページネーション上限超過", async () => {
    const res = await app.request("/v1/tasks?per_page=101", {
      headers: { Authorization: "Bearer valid-token" },
    });
    expect(res.status).toBe(400);
  });
});

describe("POST /v1/tasks", () => {
  it("正常系: タスクを作成する", async () => {
    vi.mocked(TaskService.prototype.create).mockResolvedValue(mockTask);

    const res = await app.request("/v1/tasks", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer valid-token",
      },
      body: JSON.stringify({ title: "新しいタスク", priority: "high" }),
    });

    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.data.id).toBeDefined();
  });

  it("異常系: タイトルが空文字", async () => {
    const res = await app.request("/v1/tasks", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer valid-token",
      },
      body: JSON.stringify({ title: "" }),
    });
    expect(res.status).toBe(400);
  });

  it("異常系: タイトルが201文字", async () => {
    const res = await app.request("/v1/tasks", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer valid-token",
      },
      body: JSON.stringify({ title: "a".repeat(201) }),
    });
    expect(res.status).toBe(400);
  });

  it("異常系: 不正な status 値", async () => {
    const res = await app.request("/v1/tasks", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer valid-token",
      },
      body: JSON.stringify({ title: "タスク", status: "invalid" }),
    });
    expect(res.status).toBe(400);
  });
});

describe("DELETE /v1/tasks/:id", () => {
  it("正常系: タスクを削除する", async () => {
    vi.mocked(TaskService.prototype.delete).mockResolvedValue(true);

    const res = await app.request(`/v1/tasks/${mockTask.id}`, {
      method: "DELETE",
      headers: { Authorization: "Bearer valid-token" },
    });
    expect(res.status).toBe(204);
  });

  it("異常系: 存在しないID", async () => {
    vi.mocked(TaskService.prototype.delete).mockResolvedValue(false);

    const res = await app.request("/v1/tasks/non-existent-id", {
      method: "DELETE",
      headers: { Authorization: "Bearer valid-token" },
    });
    expect(res.status).toBe(404);
    const body = await res.json();
    expect(body.errors[0].code).toBe("NOT_FOUND");
  });
});

5. エラーハンドリングパターン

業務で使い回せるエラーハンドリングユーティリティを Claude Code に生成させたもの。

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    public readonly code: string,
    public readonly message: string,
    public readonly statusCode: number,
    public readonly field?: string
  ) {
    super(message);
    this.name = "AppError";
  }
}

// よく使うエラーを定義済みファクトリとして用意
export const Errors = {
  notFound: (resource: string) =>
    new AppError("NOT_FOUND", `${resource}が見つかりません`, 404),
  unauthorized: () =>
    new AppError("UNAUTHORIZED", "認証が必要です", 401),
  forbidden: () =>
    new AppError("FORBIDDEN", "アクセス権限がありません", 403),
  conflict: (message: string) =>
    new AppError("CONFLICT", message, 409),
  unprocessable: (message: string, field?: string) =>
    new AppError("UNPROCESSABLE", message, 422, field),
  internal: () =>
    new AppError("INTERNAL_ERROR", "サーバーエラーが発生しました", 500),
};

// Hono 用エラーハンドラーミドルウェア
export function createErrorResponse(err: unknown) {
  if (err instanceof AppError) {
    return {
      data: null,
      meta: null,
      errors: [
        {
          code: err.code,
          message: err.message,
          ...(err.field ? { field: err.field } : {}),
        },
      ],
    };
  }

  // ZodError の場合はフィールドごとのエラーに変換
  if (err instanceof Error && err.name === "ZodError") {
    const zodErr = err as any;
    return {
      data: null,
      meta: null,
      errors: zodErr.errors.map((e: any) => ({
        code: "VALIDATION_ERROR",
        message: e.message,
        field: e.path.join("."),
      })),
    };
  }

  return {
    data: null,
    meta: null,
    errors: [{ code: "INTERNAL_ERROR", message: "予期しないエラーが発生しました" }],
  };
}

6. Swagger UI でドキュメントを自動生成する

// src/swagger.ts
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";

// 先ほど Claude Code が生成した OpenAPI YAML を読み込む
const openApiSpec = YAML.parse(
  fs.readFileSync(path.resolve("./openapi.yaml"), "utf8")
);

export function setupSwagger(app: OpenAPIHono) {
  // /docs にアクセスすると Swagger UI が表示される
  app.get("/docs", swaggerUI({ url: "/openapi.json" }));

  // OpenAPI JSON エンドポイント
  app.get("/openapi.json", (c) => c.json(openApiSpec));

  console.log("Swagger UI: http://localhost:3000/docs");
}

本番環境では NODE_ENV === "production" の場合は /docs を無効にするか、Basic 認証をかけることを推奨する。


7. 著者 Masa が実際に使っているパターン集

パターン1: 「仕様書駆動プロンプティング」

OpenAPI YAML を .claude/commands/api-impl.md に保存しておき、毎回参照させる。

# api-impl コマンド

以下の openapi.yaml に従って実装してください:

$ARGUMENTS

制約:
- Hono + TypeScript を使用
- バリデーションは zod
- レスポンスは必ず { data, meta, errors } 形式
- エラーは src/lib/errors.ts の Errors ファクトリを使う
- テストは vitest で正常系・異常系・エッジケースを網羅

使い方:

claude /api-impl "POST /tasks エンドポイントを追加"

パターン2: PATCH vs PUT の使い分けを徹底させる

業務でよくあるバグは「PUT のつもりで PATCH を使う」こと。Claude Code に毎回確認させるプロンプトを用意している:

このエンドポイントは全フィールドの更新(PUT)ですか、
それとも部分更新(PATCH)ですか?
PUTの場合は省略されたフィールドをnullまたはデフォルト値にリセットすること。
PATCHの場合はundefinedフィールドは変更しないこと。

パターン3: N+1問題の検出プロンプト

以下のサービスコードにN+1クエリの問題がないか確認して。
問題があれば、DataLoader または JOIN クエリに修正して。

落とし穴3選:ハマりやすいポイント

落とし穴1: UUID バリデーションの抜け漏れ

Claude Code が生成するコードは、パスパラメータの :id を単なる文字列として扱う場合がある。UUID 形式チェックを入れないと、データベースに不正なクエリが飛ぶ。

// NG: 文字列そのままDBへ
const task = await db.findById(c.req.param("id"));

// OK: UUID 形式をバリデーションしてから使う
const idSchema = z.string().uuid("有効なUUID形式ではありません");
const parsed = idSchema.safeParse(c.req.param("id"));
if (!parsed.success) {
  return c.json({ data: null, meta: null, errors: [{ code: "INVALID_ID", message: "IDの形式が不正です" }] }, 400);
}
const task = await db.findById(parsed.data);

落とし穴2: Content-Type ヘッダーの検証漏れ

PUT/POST で Content-Type: application/json がない場合、Hono は body を空オブジェクトとして扱う。zod バリデーションが通ってしまい、空データが保存されることがある。

// src/middleware/contentType.ts
import { createMiddleware } from "hono/factory";

export const requireJson = createMiddleware(async (c, next) => {
  if (["POST", "PUT", "PATCH"].includes(c.req.method)) {
    const contentType = c.req.header("Content-Type");
    if (!contentType?.includes("application/json")) {
      return c.json(
        { data: null, meta: null, errors: [{ code: "UNSUPPORTED_MEDIA_TYPE", message: "Content-Type: application/json が必要です" }] },
        415
      );
    }
  }
  await next();
});

落とし穴3: 日時フィールドのタイムゾーン問題

z.string().datetime() は UTC 形式(末尾 Z または +00:00)のみを受け付ける。日本のフロントエンドから 2026-04-25T09:00:00+09:00 を送ると弾かれる。

// NG: タイムゾーンオフセット付きの日時を受け付けない
due_date: z.string().datetime()

// OK: タイムゾーンオフセットを許可する
due_date: z.string().datetime({ offset: true }).optional()

まとめ

Claude Code を REST API 開発に組み込むことで、工数を大幅に削減できる。重要なポイントを整理する:

  1. OpenAPI仕様書を最初に生成させる — 後続の実装・テスト・ドキュメントすべての起点になる
  2. Zod スキーマは仕様書から生成させる — 手書きすると仕様書と乖離する
  3. テストは「正常系・異常系・エッジケース」を明示的に指示する — 指示しないと正常系しか書かれない
  4. 落とし穴はプロンプトに事前に含める — UUID バリデーション、Content-Type チェック、タイムゾーン処理

私の業務では、この方法論を導入してから 1エンドポイントあたりの開発時間が約70%削減 された。

ただし、Claude Code が生成するコードをそのまま使うのは危険だ。特にセキュリティ(SQL インジェクション対策、入力サニタイズ)とビジネスロジック(料金計算、在庫管理など)は必ず人の目でレビューすること。Claude Code は「下書き」を高速で作ってくれるツールであり、最終的な品質責任は開発者にある。

実際にこの記事で紹介したコードをプロジェクトに導入したところ、チームメンバー(バックエンド経験2年)がOpenAPI仕様書から実装完了まで半日で終わらせることができた。ぜひ試してみてほしい。


関連記事:

#claude-code #rest-api #openapi #typescript #backend

Claude Codeをもっと活用しませんか?

実務で使えるプロンプトテンプレート50選。コピペですぐ使えます。

無料プレゼント

無料PDF: Claude Code 5分でわかるチートシート

メールアドレスを登録するだけで、A4 1枚のチートシートPDFを今すぐお送りします。

個人情報は厳重に管理し、スパムは送りません。

Masa

この記事を書いた人

Masa

現役DX室長|Claude Code でゼロから多言語AI技術メディア運営中。実務直結の自動化、AI開発相談・研修受付中。

PR

関連書籍・参考図書

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

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