Claude CodeでREST APIを初めて作る: CRUD、バリデーション、テスト入門
Claude CodeでExpress REST APIを初めて作る入門。CRUD、バリデーション、エラー設計、テストを実例コードで解説。
REST APIを初めて作るとき、一番つまずきやすいのは「何をどこまで作ればAPIと呼べるのか」が見えないことです。Claude Codeに「APIを作って」とだけ頼むと、動くコードは出ても、URLの決め方、入力チェック、エラーの返し方、テストの仕方があいまいなまま残りがちです。
この記事では、Claude Codeを使いながら Expressで小さなTodo REST APIを作り、CRUD、バリデーション、エラー設計、テストリクエストまで一気通貫で確認する ところまで進めます。コードは現在のNode.js 22/24 LTS以降、またはそれより新しいNode.jsで動く素直なJavaScriptです。
先に基礎を固めたい場合はClaude Code入門ガイドも合わせて読むと、依頼の出し方がつかみやすくなります。APIテストを深掘りするならClaude Code APIテスト入門、スキーマ検証を本格化するならZodバリデーション実装へ進んでください。
まず用語を短く押さえる
REST APIは、HTTPを使って「リソース」と呼ばれるデータを操作するための設計スタイルです。今回のリソースはtodoです。難しい言い方を避けるなら、「フロントエンドや別のサービスが、URLへリクエストを送り、JSONで結果を受け取る窓口」と考えれば十分です。
初心者が最初に覚えるべき用語は5つです。
| 用語 | 平易な意味 | この記事での例 |
|---|---|---|
| エンドポイント | APIの入口になるURL | GET /todos |
| メソッド | 何をしたいかを表すHTTPの動詞 | GET, POST, PUT, DELETE |
| ステータスコード | 結果を数字で伝える合図 | 200, 201, 400, 404 |
| JSON | データを受け渡す軽いテキスト形式 | { "title": "Learn REST" } |
| バリデーション | 入力が正しいか確認する処理 | 空のtitleを拒否する |
MDNのHTTPリクエストメソッドとHTTPステータスコードは、迷ったときの公式リファレンスとして使えます。ExpressのルーティングはExpress routing、エラー処理はExpress error handlingが一次情報です。
今回作るAPIと3つのユースケース
作るのは、メモリ上にTodoを保存する最小APIです。データベースは使いません。これは本番向けではありませんが、REST APIの形を理解するには十分です。
| 操作 | メソッドとURL | 成功時の主なコード |
|---|---|---|
| 疎通確認 | GET /health | 200 OK |
| Todo一覧 | GET /todos | 200 OK |
| Todo詳細 | GET /todos/:id | 200 OK |
| Todo作成 | POST /todos | 201 Created |
| Todo更新 | PUT /todos/:id | 200 OK |
| Todo削除 | DELETE /todos/:id | 204 No Content |
この題材は小さいですが、実務の入口としてはかなり使えます。
1つ目のユースケースは、社内のタスク管理APIです。Todoを案件、問い合わせ、レビュー依頼に置き換えれば、一覧、作成、更新、削除の基本形はほぼ同じです。
2つ目は、フロントエンド開発用のモックAPIです。ReactやVueの画面を先に作るとき、仮のAPIがあるだけでフォーム、ローディング、エラー表示を早く試せます。
3つ目は、問い合わせやニュースレター登録の小さなバックエンドです。titleをemailやmessageに変えれば、入力検証とエラー設計の考え方をそのまま使えます。
Claude Codeへの依頼は完成条件まで書く
Claude Codeには、使う技術、作るエンドポイント、失敗時の挙動、確認コマンドをまとめて渡します。短く「ExpressでAPI作って」と頼むより、完成条件を入れた方がレビューしやすいコードになります。
Express 5とNode.jsのES Modulesで、初心者向けのTodo REST APIを作ってください。
条件:
- package.jsonとserver.jsを用意する
- GET /health, GET /todos, GET /todos/:id, POST /todos, PUT /todos/:id, DELETE /todos/:idを作る
- 入力はJSONにする
- titleは1文字以上120文字以下、completedはbooleanだけ許可する
- 400, 404, 500のJSONエラーを返す
- PUTは同じリクエストを何度送っても最終状態が変わらないようにする
- curlで確認できるリクエスト例とnode:testのテスト例も出す
ここで「PUTは同じリクエストを何度送っても最終状態が変わらない」と書いているのは、冪等性を守るためです。冪等性とは、同じ操作を何度繰り返しても結果の状態が変わらない性質です。たとえばPUT /todos/1で同じtitleとcompletedを送るなら、2回目以降もTodoは同じ状態のままです。
最小プロジェクトを作る
空のフォルダで次を実行します。PowerShellでcurlが別コマンドに解釈される環境では、後の確認コマンドだけcurl.exeを使ってください。
mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express
package.jsonを次の内容にします。
{
"name": "claude-rest-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.js",
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.0.0"
}
}
server.js
次がAPI本体です。データはメモリ上に置くので、サーバーを再起動すると初期状態に戻ります。実務ではここをPostgreSQL、MySQL、SQLite、Firestoreなどに置き換えます。
import express from "express";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT ?? 3000);
function createHttpError(status, message, details) {
const error = new Error(message);
error.status = status;
error.details = details;
return error;
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function parseId(rawId) {
const id = Number(rawId);
return Number.isInteger(id) && id > 0 ? id : null;
}
function validateTodoInput(body, { partial = false } = {}) {
const errors = [];
if (!partial || hasOwn(body, "title")) {
if (typeof body.title !== "string" || body.title.trim().length === 0) {
errors.push({ field: "title", message: "title is required" });
} else if (body.title.trim().length > 120) {
errors.push({ field: "title", message: "title must be 120 characters or fewer" });
}
}
if (!partial || hasOwn(body, "completed")) {
if (typeof body.completed !== "boolean") {
errors.push({ field: "completed", message: "completed must be a boolean" });
}
}
return errors;
}
export function createApp() {
const app = express();
let nextId = 3;
const todos = [
{
id: 1,
title: "Read MDN HTTP status docs",
completed: false,
updatedAt: "2026-06-03T00:00:00.000Z"
},
{
id: 2,
title: "Ask Claude Code to review API errors",
completed: true,
updatedAt: "2026-06-03T00:00:00.000Z"
}
];
app.use(express.json({ limit: "32kb" }));
app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.get("/todos", (req, res) => {
res.json({ data: todos, count: todos.length });
});
app.get("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const todo = todos.find((item) => item.id === id);
if (!todo) return next(createHttpError(404, "todo not found"));
res.json({ data: todo });
});
app.post("/todos", (req, res, next) => {
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const todo = {
id: nextId,
title: req.body.title.trim(),
completed: req.body.completed,
updatedAt: new Date().toISOString()
};
nextId += 1;
todos.push(todo);
res.status(201).location(`/todos/${todo.id}`).json({ data: todo });
});
app.put("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
const previous = todos[index];
const nextTodo = {
...previous,
title: req.body.title.trim(),
completed: req.body.completed
};
const changed =
previous.title !== nextTodo.title || previous.completed !== nextTodo.completed;
todos[index] = {
...nextTodo,
updatedAt: changed ? new Date().toISOString() : previous.updatedAt
};
res.json({ data: todos[index] });
});
app.delete("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
todos.splice(index, 1);
res.status(204).send();
});
app.use((req, res, next) => {
next(createHttpError(404, `route not found: ${req.method} ${req.originalUrl}`));
});
app.use((err, req, res, next) => {
const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 500;
const body = {
error: {
status,
message: status === 500 ? "Internal Server Error" : err.message
}
};
if (err.details) {
body.error.details = err.details;
}
res.status(status).json(body);
});
return app;
}
const currentFile = fileURLToPath(import.meta.url);
const startedFile = process.argv[1] ? resolve(process.argv[1]) : "";
if (startedFile === currentFile) {
createApp().listen(PORT, () => {
console.log(`REST API listening on http://localhost:${PORT}`);
});
}
このコードで大事なのは、ルートを並べただけで終わっていない点です。validateTodoInputで入力を確認し、createHttpErrorでエラーの形をそろえ、最後のエラーミドルウェアでJSONとして返しています。Expressではエラー処理用ミドルウェアは(err, req, res, next)の4引数にする必要があります。
PUTでは、同じ値を送った場合にupdatedAtを更新しないようにしています。ここを毎回new Date()にすると、同じリクエストを繰り返すたびに状態が変わり、冪等性の説明とコードがずれてしまいます。初心者向けの記事ほど、このような小さな不一致を残さないことが重要です。
curlで動作確認する
サーバーを起動します。
npm run dev
別のターミナルで確認します。
curl -i http://localhost:3000/health
curl -i http://localhost:3000/todos
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write API tests","completed":false}'
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"","completed":false}'
curl -i -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Read MDN HTTP status docs","completed":true}'
curl -i -X DELETE http://localhost:3000/todos/2
成功、入力エラー、未存在エラーを全部見てください。APIは「成功したときだけ動く」では足りません。入力が空のとき、IDが数字でないとき、存在しないTodoを指定したときに、フロントエンドが判断できるJSONを返す必要があります。
node:testで最低限のテストを入れる
Node.jsには公式のテストランナーがあります。詳しくはNode.js test runnerを確認してください。ここでは外部のテストライブラリを増やさず、node:testとfetchだけで確認します。
server.test.jsを作ります。
import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "./server.js";
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => resolve(server));
});
}
async function request(baseUrl, path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...options.headers },
...options
});
const text = await response.text();
return {
response,
body: text ? JSON.parse(text) : null
};
}
test("creates, updates, and deletes a todo", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "Test the API", completed: false })
});
assert.equal(created.response.status, 201);
assert.equal(created.body.data.title, "Test the API");
const id = created.body.data.id;
const updated = await request(baseUrl, `/todos/${id}`, {
method: "PUT",
body: JSON.stringify({ title: "Test the API", completed: true })
});
assert.equal(updated.response.status, 200);
assert.equal(updated.body.data.completed, true);
const deleted = await request(baseUrl, `/todos/${id}`, { method: "DELETE" });
assert.equal(deleted.response.status, 204);
});
test("rejects invalid todo input", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const result = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "", completed: false })
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.details[0].field, "title");
});
実行します。
npm test
テストは少なくても、作成、更新、削除、バリデーションエラーを通しておくと、Claude Codeに次の変更を頼むときの安全網になります。たとえば次にデータベースを入れるとき、「既存のnpm testが通るように実装して」と依頼できます。
よくある落とし穴
1つ目は、エンドポイント名を動詞だらけにすることです。/getTodosや/createTodoではなく、GET /todosとPOST /todosに分ける方がRESTらしく、クライアント側も覚えやすくなります。
2つ目は、ステータスコードを全部200にすることです。入力ミスは400、存在しないリソースは404、作成成功は201、削除成功で本文がないなら204を使います。数字は飾りではなく、フロントエンドや監視が判断するための契約です。
3つ目は、JSONの形が成功時と失敗時で毎回変わることです。この記事では成功時を{ data: ... }、失敗時を{ error: ... }に寄せました。最初から完璧な規格にする必要はありませんが、同じAPI内ではそろえるべきです。
4つ目は、バリデーションをフロントエンドだけに置くことです。ブラウザのフォームで空欄を止めても、APIは直接呼ばれます。サーバー側で必ず検証し、何が悪いのかをdetailsで返すとデバッグしやすくなります。
5つ目は、冪等性を壊す更新です。PUTなのに毎回ランダムな値や新しいタイムスタンプを無条件に入れると、「同じリクエストを繰り返しても同じ状態」という説明が崩れます。監査ログを別に記録する設計なら問題ありませんが、リソース本体の意味は意識してください。
6つ目は、開発中のスタックトレースをそのまま本番レスポンスへ出すことです。この記事のサンプルでは500時に一般的なメッセージだけ返します。本番ではログに詳細を残し、クライアントへは必要最小限の情報だけ返します。
次にClaude Codeへ頼むなら
ここまでできたら、次の拡張は小さく切ります。いきなり認証、DB、OpenAPI、Dockerを全部入れると、初心者にはレビューできない差分になります。
おすすめの順番は、まずSQLiteやPostgreSQLへ保存先を変えることです。次にZodなどのスキーマ検証を入れ、最後にOpenAPIドキュメントやPlaywright/APIテストを足します。エラー設計の考え方はエラーハンドリングパターンも参考になります。
Claude Codeへの依頼文は、次のように「変更範囲」と「検証」を入れると安定します。
このExpress APIにSQLite保存を追加してください。
条件:
- 既存のエンドポイント仕様は変えない
- server.test.jsが通るようにする
- DB初期化処理を分ける
- 変更後にnpm testを実行し、失敗した場合は原因を説明する
REST APIを自分の題材で練習したい場合は、まず無料チートシートでClaude Codeへの依頼の型を手元に置いてください。実務で使うプロンプト、レビュー観点、CLAUDE.mdの整備までまとめたい場合はClaude Code教材一覧が近道です。チーム導入、権限設計、APIレビュー運用まで相談したい場合はClaude Code研修・導入相談を使ってください。
この記事で紹介した内容を実際に試した結果、初心者が最初に理解しやすかったのは「CRUDコード」そのものより、成功と失敗を同じ形で確認する流れでした。特に、空のtitleで400を返すこと、存在しないIDで404を返すこと、同じPUTを繰り返しても状態が変わらないことをcurlとnode:testで見せると、REST APIが単なるURL集ではなく、フロントエンドと約束する契約だと説明しやすくなりました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。