REST API入門:初めての最小APIをClaude Codeと60分で動かす
REST API入門。HTTPメソッドとステータスコードの基礎から、Claude CodeでTodo APIをCRUDまで作り、curlとテストで動かす最初の一歩を実体験で解説。
「APIを作って」とClaude Codeに頼んだら、30秒でコードが返ってきました。
動かしてみたら、たしかに動く。でも僕は何ひとつ理解していませんでした。なぜURLがこの形なのか、なぜ201なのか、空っぽのデータを送ったら何が起きるのか——全部ブラックボックスのまま。
そこで一度、AIに丸投げするのをやめて、自分の手で一番小さいREST APIを作り直しました。この記事はそのときのメモです。HTTPの基礎を押さえながら、Todoを1件作って、読んで、消すところまで。終わるころには「APIって、こういうことか」と腹落ちしているはずです。
この記事の要点
- REST APIは「URLにリクエストを送ると、JSONで結果が返ってくる窓口」。最初に覚えるのはメソッド・ステータスコード・JSON・バリデーションの4語だけでいい。
- 題材はメモリ上のTodo API。DBもログインも使わない最小構成だから、APIの「形」だけに集中できる。
- Claude Codeへの依頼は「作って」ではなく、エンドポイント・エラー・確認方法まで条件を書くと理解しやすいコードが返る。
- 動作確認は
curlで成功と失敗を両方見る。さらにnode:testを1本通すと、次の改造が怖くなくなる。 - 設計を深掘りしたくなったら別記事へ。本記事はあくまで**「初めての1本を最後まで動かす」**ことに振り切っている。
REST APIって、結局なに?
最初に身構える必要はありません。REST APIは、HTTPを使ってデータをやり取りする窓口です。それだけです。
たとえばスマホの天気アプリ。あれは裏側で「東京の天気ちょうだい」というリクエストを天気サーバーのURLへ送り、気温や降水確率をJSONで受け取って画面に並べているだけです。あなたが今から作るのも、これのうんと小さい版です。
今回あつかうデータ(RESTでは「リソース」と呼びます)はtodo、つまりやることリストの1項目です。難しい定義を覚えるより、こう考えてください。フロントエンドや別のサービスが、URLへリクエストを送り、JSONで答えを受け取る。その受付窓口がREST APIです。
初心者が最初に覚える用語は、欲張らず5つでいいです。
| 用語 | やさしい意味 | この記事での例 |
|---|---|---|
| エンドポイント | APIの入口になるURL | GET /todos |
| メソッド | 何をしたいかを表すHTTPの動詞 | GET POST PUT DELETE |
| ステータスコード | 結果を数字で伝える合図 | 200 201 400 404 |
| JSON | データを運ぶ軽いテキスト形式 | { "title": "牛乳を買う" } |
| バリデーション | 入力が正しいか確かめる処理 | 空のtitleを拒否する |
メソッドは「動詞」、エンドポイントは「名詞」と覚えると一気に楽になります。GET /todosは「todoたちを取ってくる」、POST /todosは「todoたちに1件足す」。日本語にするとそのまま読めますよね。
迷ったら公式の一次情報に戻れます。MDNのHTTPリクエストメソッドとHTTPステータスコードは、僕も今でも開きっぱなしにしている定番です。
そもそもClaude Codeの使い方からつかみたい人は、先にClaude Code入門ガイドに目を通すと、この記事の依頼文がすっと入ってきます。
ステータスコードは「数字のあいさつ」
初心者がいちばん雑に扱うのがステータスコードです。全部200で返してしまう。でもこれ、APIとフロントエンドが交わす約束ごとなんです。
数字の頭1桁だけ覚えれば十分です。
- 2xx(成功):
200 OKは普通の成功、201 Createdは新しく作れた、204 No Contentは成功したけど返す中身はない(削除など)。 - 4xx(あなたのリクエストが悪い):
400 Bad Requestは入力ミス、404 Not Foundは指定したものが無い。 - 5xx(サーバー側が悪い):
500 Internal Server Errorはサーバーが転んだ。
なぜ大事か。フロントエンドは「画面にエラーを出すべきか、もう一度試すべきか」を、この数字を見て自動で判断します。全部200にすると、エラーなのに成功扱いされて、原因不明のバグが生まれます。数字は飾りじゃなくて契約、と腹に入れておいてください。
今回作る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 |
題材は小さくても、つぶしは効きます。
ひとつ目は、社内のタスク管理APIです。Todoを「案件」「問い合わせ」「レビュー依頼」に置き換えれば、一覧・作成・更新・削除の骨格はほぼそのまま使えます。
ふたつ目は、フロント開発用のモックAPIです。ReactやVueの画面を先に作るとき、仮のAPIがあるだけでフォーム・ローディング・エラー表示を早く試せます。僕はデザイン確認のためだけに、この最小APIを何度も使い回しています。
みっつ目は、問い合わせやニュースレター登録の小さなバックエンドです。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に同じ内容を2回送っても、Todoは同じ状態のまま——それが冪等です。条件に書いておくと、コードと説明がズレません。
最小プロジェクトを作る
空のフォルダで次を実行します。Windowsの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"
}
}
コードは現行のNode.js 22/24 LTS以降、またはそれより新しいNode.jsで動く素直なJavaScriptです。fetchもテストランナーも標準で入っているので、追加ライブラリはExpressだけで足ります。
server.js:これが本体
次がAPI本体です。データはメモリ上に置くので、起動するたびに初期状態へ戻ります。実務ではここをPostgreSQLやSQLiteなどに差し替えます。
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);
}
// IDは「正の整数」だけ通す。数字でなければ null を返して弾く
function parseId(rawId) {
const id = Number(rawId);
return Number.isInteger(id) && id > 0 ? id : null;
}
// 入力チェック。partial=true なら送られた項目だけ検証する
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: "MDNでHTTPステータスを読む", completed: false, updatedAt: "2026-06-06T00:00:00.000Z" },
{ id: 2, title: "Claude CodeにAPIのエラーを見てもらう", completed: true, updatedAt: "2026-06-06T00: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 });
});
// 作成:成功したら 201 を返す
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 });
});
// 更新:同じ値なら updatedAt を変えない(冪等性のため)
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] });
});
// 削除:成功したら本文なしの 204
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();
});
// 知らないURLは 404
app.use((req, res, next) => {
next(createHttpError(404, `route not found: ${req.method} ${req.originalUrl}`));
});
// エラーをJSONで返す係。Expressのエラー処理は引数4つが必須
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として返す。この3段構えがあるから、成功も失敗も同じ作法で返せます。Expressのエラー処理ミドルウェアは(err, req, res, next)の4引数にしないと認識されない点だけ、初学者は必ず踏みます。詳しくはExpressのエラー処理が一次情報です。
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":"APIのテストを書く","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":"MDNでHTTPステータスを読む","completed":true}'
curl -i -X DELETE http://localhost:3000/todos/2
-iを付けるとレスポンスヘッダーも出るので、ステータスコードがその場で読めます。ここで絶対にやってほしいのは、成功だけでなく失敗も見ることです。空のtitleを送ったときに400が返るか、存在しないIDで404が返るか。APIは「うまくいったときだけ動く」では不合格で、ダメなときにフロントが判断できるJSONを返して初めて一人前です。
node:testで安全網を1本張る
最後に、Node.js標準のテストランナーで最低限のテストを入れます。外部ライブラリは増やさず、node:testとfetchだけで完結します。仕様はNode.js test runnerが一次情報です。
server.test.jsを作ります。
import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "./server.js";
// ポート0で起動すると空きポートを自動で割り当ててくれる
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("作成・更新・削除がひと通り動く", 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: "APIをテストする", completed: false })
});
assert.equal(created.response.status, 201);
assert.equal(created.body.data.title, "APIをテストする");
const id = created.body.data.id;
const updated = await request(baseUrl, `/todos/${id}`, {
method: "PUT",
body: JSON.stringify({ title: "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("不正な入力は弾く", 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
テストは少なくていいんです。作成・更新・削除・入力エラーのこの4本さえ通しておけば、次の改造が怖くなくなる。たとえばDBを入れるとき、Claude Codeに「既存のnpm testが通るように実装して」と頼める。これが効きます。
僕がつまずいた3つの落とし穴
正直に書くと、最初の自作APIは穴だらけでした。あなたが同じ轍を踏まないように共有します。
ひとつ目は、エンドポイントを動詞だらけにしたこと。/getTodosや/createTodoと書いていました。RESTでは動詞はメソッドが担うので、URLは名詞/todosにして、GETかPOSTかで動作を分けるのが筋です。クライアント側も覚える数が減ります。
ふたつ目は、成功と失敗でJSONの形がバラバラだったこと。成功時は{ data: ... }、失敗時は{ error: ... }、と形をそろえてから、フロント側のコードが一気に短くなりました。最初から完璧な規格は要りませんが、同じAPI内ではそろえるべきです。
みっつ目は、バリデーションをフロントだけに置いたこと。ブラウザのフォームで空欄を止めても、APIはcurlから直接呼べてしまいます。サーバー側で必ず検証し、何が悪いかをdetailsで返す。これをサボると、本番で謎のデータが混入します。
ちなみに、エラー設計をもっと体系立てて学びたくなったらエラーハンドリングパターンが次の一冊になります。
よくある質問
Q. REST APIとWeb APIは何が違うんですか? A. Web APIは「Web経由で使えるAPI全般」を指すざっくりした言葉で、RESTはその中の設計スタイルのひとつです。URLでリソースを表し、HTTPメソッドで操作を分けるのがRESTの流儀。今回作ったTodo APIはRESTスタイルのWeb APIにあたります。
Q. POSTとPUTはどう使い分けますか? A. ざっくり「新規作成はPOST、まるごと置き換えはPUT」です。POSTは送るたびに新しいTodoが増えます。PUTは同じIDへ同じ内容を何度送っても結果が変わらない(冪等)ように作ります。部分的に直したいときはPATCHを使う流派もありますが、入門段階では2つだけで十分です。
Q. データベースなしのAPIに意味はありますか? A. 学習なら大いにあります。メモリ上に置くと「APIの形」だけに集中でき、再起動で全部消えるのも仕組みが見えて理解が早いです。実務へ進むときに、保存先をSQLiteやPostgreSQLへ差し替えれば、エンドポイントの形はそのまま使えます。
Q. Claude Codeに全部作らせるのはダメですか? A. ダメではありません。ただ、僕のように「動くけど分からない」状態になりがちです。最初の1本だけは、依頼文に完成条件を書いたうえで、返ってきたコードを1行ずつ読むのをおすすめします。一度通せば、2本目からは安心して任せられます。
Q. 次は何を勉強すればいいですか? A. まず保存先をDBに変え、次にZodなどでスキーマ検証を入れ、最後にOpenAPIドキュメントやAPIテストを足す——この順番が無理なく伸びます。設計の観点で全体像を固めたいならREST API設計を固めるへ、テストを深掘りするならClaude Code APIテスト入門へ進んでください。
実際に試した結果
冒頭の「動くけど分からないAPI」を、自分の手で作り直して気づいたことがあります。初心者が最初につまずくのは、実はCRUDのコードそのものではなく、成功と失敗を同じ作法で確認する流れでした。
空のtitleで400が返る。存在しないIDで404が返る。同じPUTを繰り返しても状態が変わらない。これをcurlとnode:testで目で見た瞬間に、REST APIが「ただのURLの集まり」ではなく、フロントエンドと交わす契約なんだと腹落ちしました。Claude Codeはその契約を一瞬で書いてくれますが、契約の中身を読めるのは自分だけです。だから最初の1本は、面倒でも手で通す価値がある。これが今の僕の実感です。
最初の依頼の「型」を手元に置きたい人は、まず無料チートシートをどうぞ。Claude Codeへの依頼文・レビュー観点・CLAUDE.mdの整備までまとめて学びたいならClaude Code教材一覧が近道です。チーム導入や権限設計、APIレビュー運用まで相談したいときはClaude Code研修・導入相談を覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。