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つの理由
- OpenAPI仕様書をコンテキストとして理解できる — YAMLを貼り付けるだけで、その仕様に準拠したコードを生成してくれる
- TypeScript の型システムを熟知している — 型安全なコードを最初から書いてくれるため、後から型エラーで詰まることが激減する
- テストパターンを大量に知っている — 正常系・異常系・エッジケースを網羅したテストを一括生成できる
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 開発に組み込むことで、工数を大幅に削減できる。重要なポイントを整理する:
- OpenAPI仕様書を最初に生成させる — 後続の実装・テスト・ドキュメントすべての起点になる
- Zod スキーマは仕様書から生成させる — 手書きすると仕様書と乖離する
- テストは「正常系・異常系・エッジケース」を明示的に指示する — 指示しないと正常系しか書かれない
- 落とし穴はプロンプトに事前に含める — UUID バリデーション、Content-Type チェック、タイムゾーン処理
私の業務では、この方法論を導入してから 1エンドポイントあたりの開発時間が約70%削減 された。
ただし、Claude Code が生成するコードをそのまま使うのは危険だ。特にセキュリティ(SQL インジェクション対策、入力サニタイズ)とビジネスロジック(料金計算、在庫管理など)は必ず人の目でレビューすること。Claude Code は「下書き」を高速で作ってくれるツールであり、最終的な品質責任は開発者にある。
実際にこの記事で紹介したコードをプロジェクトに導入したところ、チームメンバー(バックエンド経験2年)がOpenAPI仕様書から実装完了まで半日で終わらせることができた。ぜひ試してみてほしい。
関連記事:
無料PDF: Claude Code 5分でわかるチートシート
メールアドレスを登録するだけで、A4 1枚のチートシートPDFを今すぐお送りします。
個人情報は厳重に管理し、スパムは送りません。
この記事を書いた人
Masa
現役DX室長|Claude Code でゼロから多言語AI技術メディア運営中。実務直結の自動化、AI開発相談・研修受付中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code 完全入門ガイド2026|ゼロから実務で使えるまでの7ステップ
Claude Codeを初めて触る方向けの完全入門ガイド。インストールから実際の開発ワークフローへの組み込みまで、Masa自身が最初につまずいたポイントを踏まえて丁寧に解説。
Claude Code で REST API を作ってみよう|初心者でもわかる実践入門
REST APIの基礎をClaude Codeと一緒に学ぶ入門ガイド。エンドポイント設計からバリデーション・エラーハンドリングまで、コピペで動くコードで丁寧に解説。
Claude Code vs Gemini CLI 徹底比較2026|Google製AIと何が違うか実際に使って検証
Claude CodeとGemini CLIを実際に使って徹底比較。価格・自律度・コンテキスト窓・エコシステムの違いを現役DXエンジニアMasaが検証。どちらを選ぶべきか判断フローも公開。