この記事は、API通信を 基礎知識ゼロから実務で通用するところまで 学ぶシリーズ(全4回)の 第1回 です。同じ「ToDo API」を REST・gRPC・Connect の3方式で作りながら、仕組み・実装・実務での運用を順に解説します。
| 回 | 内容 | 章 |
|---|---|---|
| 第1回 | Web通信の基礎とREST | 00〜03 |
| 第2回 | gRPCとProtocol Buffers | 04〜06 |
| 第3回 | Connect | 07〜09 |
| 第4回 | 技術選定とミドルへのステップアップ | 10〜11・用語集 |
はじめに
アプリ同士が「どうやって話しているのか」を、REST・gRPC・Connectの3つの方式で学ぶシリーズです。仕組みを図と実際のバイト列で理解し、TypeScriptで手を動かし、最後に「現場でどう導入・運用するか」まで到達することがゴールです。
ジュニアとミドルの一番大きな差は「動くコードが書けるか」ではなく、なぜその設計なのかを説明でき、壊れたときや変更するときのことまで考えられるかです。そのため各章は次の3段構えになっています。
-
① しくみ
通信の中で実際に何が流れているのかを、図と生のデータで確認します。ここが分かるとエラーの原因が推測できるようになります。 -
② 実装
同じ「ToDo API」を3方式で作ります。題材を揃えているので、方式ごとの違いがそのまま見えます。 -
③ 実務
エラー設計、互換性、認証、タイムアウト、監視、CI、段階的な移行など、現場で求められる判断を扱います。
この記事の読み方
基礎知識ゼロなら、00章から順番に読んでください。各章の最後に「理解度チェック」があります。答えを開く前に自分の言葉で説明してみると、理解が定着します。
「ミドルへの視点」と書かれた枠は、ミドルエンジニアならここまで考える、というポイントです。最初は読み飛ばしても構いません。実務に入ってから読み返すと効きます。
第1回では、すべての土台になる HTTP の基礎と、REST API の考え方・実装・実務での設計を扱います。
動作環境
コードはすべて TypeScript(Node.js 20 以上)で書いています。ライブラリはそれぞれ次のメジャーバージョンを前提にしています。
- Express 5 / zod
- @grpc/grpc-js / @grpc/proto-loader
- @connectrpc/connect v2 / @bufbuild/protobuf v2 / Buf CLI
メジャーバージョンが変わると API が変わることがあるので、実装するときは公式ドキュメントも併せて確認してください。
00 Web通信の基礎
REST も gRPC も Connect も、すべて「HTTP」という共通のルールの上で動いています。まずはこの土台を固めます。ここを飛ばすと、後の章が暗記になってしまいます。
この章のゴール
- API・クライアント・サーバーの関係を説明できる
- HTTPリクエストとレスポンスの中身を、生のテキストで読める
- HTTP/1.1 と HTTP/2 の違いを一言で言える
APIとは何か
API(Application Programming Interface)は、プログラム同士がやりとりするための窓口と約束事です。人間が画面を操作する代わりに、プログラムが決まった形式でお願いを送り、決まった形式で結果を受け取ります。
たとえ
レストランを想像してください。お客さん(クライアント)は厨房(サーバー)に直接入れません。代わりにメニューを見て、店員に注文票を渡します。
このとき「メニューに何が載っているか」「注文票の書き方」「料理がどんな形で出てくるか」という約束事全体が API です。REST・gRPC・Connectは、この注文票の書き方の流派だと思ってください。
クライアントとサーバー
通信には必ず「お願いする側」と「応える側」がいます。
- クライアント:リクエスト(お願い)を送る側。ブラウザ、スマホアプリ、別のサーバーなど。
- サーバー:リクエストを受けて処理し、レスポンス(返事)を返す側。
同じプログラムが両方の役をすることもよくあります。たとえば「注文サービス」はブラウザから見ればサーバーですが、「在庫サービス」に問い合わせるときはクライアントです。後で出てくるマイクロサービスの世界では、この関係が何重にも連なります。
HTTP:リクエストとレスポンスの中身
HTTPは、クライアントとサーバーが会話するための文章の書式です。実はHTTP/1.1のやりとりは、ただのテキストです。ブラウザでWebページを開くたびに、裏では次のような文字列が送られています。
リクエスト(クライアント → サーバー)
POST /todos HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
Content-Length: 27
{"title":"牛乳を買う"}
1行目がリクエストラインで、「何をしたいか(メソッド)」「どこに対して(パス)」「どのルールで(HTTPバージョン)」の3つが書かれています。続く行がヘッダー(付帯情報)で、空行のあとがボディ(本文)です。
レスポンス(サーバー → クライアント)
HTTP/1.1 201 Created
Content-Type: application/json
Location: /todos/7f3a
{"id":"7f3a","title":"牛乳を買う","done":false}
レスポンスは1行目がステータスラインで、201 のようなステータスコードで結果を伝えます。3桁の数字の先頭で大まかな意味が決まっています。
| 先頭 | 意味 | よく見る例 |
|---|---|---|
2xx |
成功 |
200 OK, 201 Created, 204 No Content
|
3xx |
別の場所を見て |
301 移動した, 304 Not Modified(キャッシュを使って) |
4xx |
クライアント側の問題 |
400 形式が不正, 401 未認証, 403 権限なし, 404 見つからない |
5xx |
サーバー側の問題 |
500 内部エラー, 503 一時的に使えない |
4xx と 5xx の区別は実務でとても重要です。4xx は「お願いの仕方が悪い」ので同じリクエストを再送しても結果は変わりません。5xx は「サーバーの都合」なので時間をおいて再送すれば成功するかもしれません。リトライするかどうかの判断はここで決まります。
よく使うヘッダー
| ヘッダー | 役割 |
|---|---|
Content-Type |
ボディの形式。JSONなら application/json。gRPCでは application/grpc になります。 |
Authorization |
「誰がお願いしているか」を示す証明書(トークン)。 |
Accept |
クライアントが受け取りたい形式。 |
Cache-Control |
レスポンスをキャッシュしてよいか、何秒まで有効か。 |
X-Request-Id など |
ログを追跡するための一意なID(慣習的なヘッダー)。 |
JSON:データの書き方
JSON(JavaScript Object Notation)は、データを人間にも読めるテキストで表す形式です。RESTではほぼこれが使われます。
{
"id": "7f3a",
"title": "牛乳を買う",
"done": false,
"tags": ["買い物", "今日"],
"assignee": null
}
使える型は、文字列・数値・真偽値・null・配列・オブジェクトの6種類だけです。シンプルで読みやすい反面、次の弱点があります。後で出てくる Protocol Buffers は、まさにこの弱点を解決するために作られました。
-
型の約束がない:
"done"が本当に真偽値で届く保証はありません。受け取る側で毎回チェックが必要です。 -
サイズが大きい:毎回
"title"のようなキー名を文字で送るので、データ量が増えます。 - 整数が危うい:JavaScriptの数値は 2^53 を超える整数を正確に扱えません。IDが大きいと壊れることがあります。
プログラム内のデータ(オブジェクト)を、JSONのような「送れる形」に変換することをシリアライズ、その逆をデシリアライズと呼びます。TypeScriptでは JSON.stringify() と JSON.parse() がそれにあたります。
HTTP/1.1 と HTTP/2 の違い
gRPCを理解するための一番大事な前提がここです。
HTTP/1.1 は、1本の接続で一度に1つのやりとりしかできません。前のレスポンスが返ってくるまで次のリクエストは待たされます。ブラウザはこれを回避するために接続を6本くらい同時に張ります。
HTTP/2 は、1本の接続の中に「ストリーム」という仮想的な通り道を何本も作り、データを小さなフレームに切って混ぜながら送ります。これを多重化(マルチプレクシング)と呼びます。さらにテキストではなくバイナリでやりとりし、ヘッダーも圧縮されます。
HTTP/1.1:1本の接続で順番待ち
[リクエストA→応答A][リクエストB→応答B][リクエストC→応答C] ← 前が終わるまで次に進めない
HTTP/2:1本の接続にフレームを混ぜて同時に流す
[A1][B1][C1][A2][C2][B2][A3][B3] ← A・B・Cを同時進行。長い通信(ストリーミング)も得意
この「1本の接続で、同時に、長時間、双方向にやりとりできる」性質が、gRPCのストリーミング機能の土台になります。また HTTP/2 にはトレーラーという「ボディを送り終わった後に付ける追加ヘッダー」の仕組みがあり、gRPCはここに処理結果(成功か失敗か)を書きます。これが後で「ブラウザからgRPCが直接使えない」問題の原因になるので、覚えておいてください。
手を動かす準備(TypeScript環境)
この資料のコードは次の手順で作ったプロジェクトで動きます。tsx は TypeScript をビルドせずにそのまま実行できる便利なツールです。
mkdir todo-api && cd todo-api
npm init -y
npm pkg set type=module # import 文を使うための設定
npm i -D typescript tsx @types/node
npx tsc --init # tsconfig.json を作成
npx tsx src/hello.ts # .ts を直接実行できる
curl について
この資料ではAPIを叩くのに curl コマンドを多用します。-X でメソッド、-H でヘッダー、-d でボディ、-i でレスポンスヘッダーも表示、という4つだけ覚えれば十分です。
理解度チェック
Q1. 503 が返ってきたときと 400 が返ってきたとき、リトライすべきなのはどちら?
503 です。5xx はサーバー側の一時的な問題の可能性があるので、少し待って再送すると成功することがあります。400 はリクエスト自体が間違っているので、何度送っても同じ結果になります。
Q2. HTTP/2 の「多重化」とは何か、一言で。
1本の接続の中に複数のストリームを作り、フレーム単位で混ぜて同時に送受信すること。順番待ちが発生しません。
Q3. JSON の弱点を2つ挙げて。
型の保証がないこと、キー名を毎回文字で送るのでサイズが大きいこと(大きな整数が壊れる問題もあります)。
01 RESTの考え方
REST は「ライブラリ」でも「プロトコル」でもなく、HTTPをこう使うと分かりやすいよ、という設計スタイル(考え方)です。だからこそ、人やチームによって解釈がぶれます。ぶれない軸を身につけましょう。
この章のゴール
- 「リソース」と「HTTPメソッド」でAPIを設計できる
- 安全性と冪等性の違いを説明できる
- 状況に合ったステータスコードを選べる
中心にあるのは「リソース」
REST(REpresentational State Transfer)の中心はリソースです。リソースとは「ToDo」「ユーザー」「注文」のような、APIで扱うモノ(名詞)のこと。各リソースには URL という住所が付きます。
そして「そのモノに何をするか(動詞)」は、URLではなく HTTPメソッドで表します。これがRESTで一番大事なルールです。
-
よくない例(動詞がURLにある)
POST /createTodoPOST /getTodo?id=1POST /deleteTodo -
RESTらしい例(名詞+メソッド)
POST /todosGET /todos/1DELETE /todos/1
ちなみに
「よくない例」のように関数名をURLにするスタイルは、実は次の章で学ぶ RPC(Remote Procedure Call) の考え方そのものです。RPCが悪いわけではなく、RESTとRPCは別の流派だということ。この違いを意識しておくと、gRPCの章がすっと入ります。
HTTPメソッドとCRUD
データ操作の基本4つ(Create・Read・Update・Delete、略してCRUD)は、HTTPメソッドにこう対応させます。
| メソッド | 操作 | 例 | 安全 | 冪等 |
|---|---|---|---|---|
GET |
取得 | GET /todos/1 |
○ | ○ |
POST |
新規作成 | POST /todos |
× | × |
PUT |
丸ごと置き換え | PUT /todos/1 |
× | ○ |
PATCH |
一部だけ更新 | PATCH /todos/1 |
× | △ |
DELETE |
削除 | DELETE /todos/1 |
× | ○ |
「安全」と「冪等(べきとう)」
安全(safe)とは、呼んでもサーバーのデータが変わらないこと。GETは何回呼んでもデータは変わりません。
冪等(idempotent)とは、1回呼んでも100回呼んでも、最終的な結果が同じこと。「ToDo 1番を削除」は2回目以降は「もう無い」だけで、結果(1番が無い状態)は同じなので冪等です。一方「ToDoを作成(POST)」は呼ぶたびに新しいToDoが増えるので冪等ではありません。
たとえ
エレベーターの「5階」ボタンは冪等です。何回押しても5階に行くだけ。自動販売機の「購入」ボタンは冪等ではありません。押した回数だけお金が減ります。
なぜこれが重要かというと、ネットワークは必ずどこかで失敗するからです。レスポンスが返ってこなかったとき、冪等な操作なら安心して再送できます。冪等でない POST を再送すると、ToDoが2つできてしまうかもしれません。この問題の実務的な解決策は 03章で扱います。
URL設計のルール
-
名詞の複数形を使う:
/todos、/users - 個別のものはIDで指す:
/todos/7f3a - 親子関係は階層で表す:
/projects/42/todos(プロジェクト42に属するToDo)。ただし深くしすぎない(2階層程度まで)。 - 絞り込み・並べ替え・ページングはクエリパラメータ:
/todos?done=false&sort=-createdAt&limit=20 - 単語区切りはケバブケースかスネークケースでチーム内で統一:
/order-items
CRUDに収まらない操作はどうする?
「ToDoを完了にする」「注文をキャンセルする」のような動作は、2つの書き方があります。実務ではどちらも見かけます。
-
状態の更新として表す:
PATCH /todos/1にボディ{"done": true}。RESTらしい書き方。 -
サブリソース(またはアクション)として表す:
POST /orders/1/cancel。キャンセルに理由や副作用(返金など)が伴うならこちらが自然です。GoogleのガイドラインではPOST /orders/1:cancelという書き方もあります。
RESTの「制約」のうち実務で効くもの
RESTには提唱者が定めた6つの制約があります。全部を暗記する必要はありませんが、次の3つは設計判断に直結します。
| 制約 | 意味 | 実務での影響 |
|---|---|---|
| ステートレス | サーバーはリクエスト間でクライアントの状態を覚えない。毎回のリクエストに必要な情報(認証トークンなど)を全部載せる。 | サーバーを何台に増やしても、どのサーバーが受けても同じ結果になる。水平スケールしやすい。 |
| キャッシュ可能 | レスポンスに「キャッシュしてよいか」を明示する。 | GETの結果をCDNやブラウザに保存させ、サーバーの負荷と応答時間を減らせる。 |
| 統一インターフェース | どのリソースも同じメソッド・同じルールで操作する。 | 初めて見るAPIでも使い方が推測できる。ドキュメントを読む量が減る。 |
ステータスコードの選び方
「何でも200で返して、ボディに {"success": false} を入れる」APIは現場でよく見かけますが、避けるべきです。監視ツール・ロードバランサー・ブラウザ・リトライ処理はステータスコードを見て動くので、正しいコードを返すこと自体が他のシステムへの情報伝達になります。
| 状況 | コード |
|---|---|
| 取得・更新に成功 | 200 OK |
作成に成功(Location ヘッダーで新しいURLを返すと親切) |
201 Created |
| 成功したが返す中身がない(削除など) | 204 No Content |
| 入力の形式が不正(必須項目がない、型が違う) | 400 Bad Request |
| ログインしていない・トークンが無効 | 401 Unauthorized |
| ログインしているが権限がない | 403 Forbidden |
| 存在しない | 404 Not Found |
| 状態が競合している(同じメールアドレスが既にある等) | 409 Conflict |
| 形式は正しいが業務ルール上処理できない | 422 Unprocessable Content |
| 呼びすぎ(レート制限) | 429 Too Many Requests |
| サーバー内部のバグ・想定外のエラー | 500 Internal Server Error |
| 一時的に使えない(メンテ・過負荷) | 503 Service Unavailable |
ミドルへの視点:401 と 403、400 と 422
401 は「あなたが誰か分からない」、403 は「誰かは分かったが許可していない」です。400 と 422 の使い分けはチームによって流儀が分かれます。大事なのはどちらが正解かではなく、チームで基準を決めて文書化し、全エンドポイントで揃えることです。
理解度チェック
Q1. PUT と PATCH の違いは?
PUT はリソースを丸ごと置き換える(送らなかった項目は消える/初期値になる)。PATCH は送った項目だけを部分的に更新する。
Q2. 「ユーザー42番のToDo一覧で、未完了のものだけ」を取得するURLを設計して。
例:GET /users/42/todos?done=false。親子関係は階層、絞り込みはクエリパラメータで表します。
Q3. タイムアウトしたPOSTをそのまま再送すると何が起きうる?
1回目が実はサーバーで成功していた場合、同じToDoが2つ作られる(二重登録)。POSTは冪等ではないため。対策は03章の「冪等キー」。
02 REST APIを実装する
Node.js で最も広く使われている Web フレームワーク Express(v5)で、ToDo の REST API を作ります。入力チェックには zod を使います。
この章のゴール
- CRUD の5エンドポイントを実装し、curl で動作確認できる
- 入力チェックとエラーレスポンスを正しく返せる
- fetch でAPIを呼ぶクライアントを書ける
全体像:リクエストはこう流れる
Express は「ミドルウェア」を順番に通してリクエストを処理します。ミドルウェアは (req, res, next) を受け取る関数で、next() を呼ぶと次へ進みます。
npm i express zod
npm i -D @types/express
サーバーのコード
まずは1ファイルで全体を書きます。コメントを読みながら、上から順に追ってください。
import express, { type Request, type Response, type NextFunction } from "express";
import { z } from "zod";
import { randomUUID } from "node:crypto";
// ---- データの型と保存場所(本番ではDBになる部分) ----
type Todo = { id: string; title: string; done: boolean };
const todos = new Map<string, Todo>();
// ---- 入力の「形」を zod で定義する ----
// 外から来たデータは信用しない。必ず形と値の範囲をチェックする。
const CreateTodoBody = z.object({
title: z.string().trim().min(1, "title は必須です").max(100),
});
const UpdateTodoBody = z.object({
title: z.string().trim().min(1).max(100).optional(),
done: z.boolean().optional(),
});
// ---- エラーレスポンスの形を統一する(RFC 9457 Problem Details) ----
function problem(res: Response, status: number, title: string, detail?: string, extra?: object) {
res.status(status).type("application/problem+json").json({
type: "about:blank", title, status, detail, ...extra,
});
}
const app = express();
app.use(express.json()); // ボディのJSONを読んで req.body に入れるミドルウェア
// 一覧取得: GET /todos?done=false
app.get("/todos", (req: Request, res: Response) => {
let items = [...todos.values()];
if (req.query.done !== undefined) {
const done = req.query.done === "true";
items = items.filter((t) => t.done === done);
}
res.json({ items });
});
// 1件取得: GET /todos/:id
app.get("/todos/:id", (req: Request, res: Response) => {
const todo = todos.get(req.params.id);
if (!todo) {
problem(res, 404, "Not Found", `todo ${req.params.id} は存在しません`);
return;
}
res.json(todo);
});
// 作成: POST /todos
app.post("/todos", (req: Request, res: Response) => {
const parsed = CreateTodoBody.safeParse(req.body);
if (!parsed.success) {
problem(res, 400, "Bad Request", "入力が不正です", { errors: parsed.error.issues });
return;
}
const todo: Todo = { id: randomUUID(), title: parsed.data.title, done: false };
todos.set(todo.id, todo);
res.status(201).location(`/todos/${todo.id}`).json(todo);
});
// 部分更新: PATCH /todos/:id
app.patch("/todos/:id", (req: Request, res: Response) => {
const todo = todos.get(req.params.id);
if (!todo) {
problem(res, 404, "Not Found", `todo ${req.params.id} は存在しません`);
return;
}
const parsed = UpdateTodoBody.safeParse(req.body);
if (!parsed.success) {
problem(res, 400, "Bad Request", "入力が不正です", { errors: parsed.error.issues });
return;
}
const updated = { ...todo, ...parsed.data };
todos.set(updated.id, updated);
res.json(updated);
});
// 削除: DELETE /todos/:id
app.delete("/todos/:id", (req: Request, res: Response) => {
todos.delete(req.params.id); // 無くてもエラーにしない = 冪等
res.status(204).end();
});
// ---- 想定外のエラーをまとめて受け止める(引数が4つのミドルウェア) ----
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(err); // 本番ではロガーに送る。詳細はクライアントに見せない
problem(res, 500, "Internal Server Error");
});
app.listen(3000, () => console.log("REST server: http://localhost:3000"));
コードのポイント
-
入力は必ず検証する
TypeScriptの型はコンパイル時にしか存在しません。実行時に外から届くデータには型の保証が一切ないので、zod のようなライブラリで形をチェックします。これを忘れるとバグやセキュリティ事故の原因になります。 -
エラーの形を1つに揃える
problem()関数で、全てのエラーを同じ形(RFC 9457 という標準形式)で返しています。クライアント側は「エラーならdetailを表示する」と1通りの処理で済みます。 -
ステータスコードを使い分ける
作成は201+Location、削除は204、見つからなければ404。01章の表どおりです。 -
内部エラーの詳細は隠す
最後のエラーハンドラーはスタックトレースをログにだけ出し、クライアントには「500」とだけ返します。内部構造を外に漏らさないためです。
curl で動かしてみる
npx tsx src/rest-server.ts # 別のターミナルで起動しておく
# 作成(-i でステータスとヘッダーも表示)
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"牛乳を買う"}'
# 一覧
curl http://localhost:3000/todos
# 完了にする(id は作成時に返ってきたものに置き換える)
curl -X PATCH http://localhost:3000/todos/<id> \
-H "Content-Type: application/json" -d '{"done":true}'
# わざと不正な入力を送る → 400 と errors が返る
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" -d '{"title":""}'
400 のときに返るレスポンス
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{"type":"about:blank","title":"Bad Request","status":400,"detail":"入力が不正です",
"errors":[{"code":"too_small","path":["title"],"message":"title は必須です", ...}]}
クライアントのコード(fetch)
ブラウザでも Node.js でも使える fetch で呼び出します。注意点は、fetch は 404 や 500 でもエラーを投げないことです。res.ok(ステータスが 200〜299 なら true)を自分で確認する必要があります。
type Todo = { id: string; title: string; done: boolean };
type Problem = { title: string; status: number; detail?: string };
const BASE = "http://localhost:3000";
// 失敗時に投げる専用のエラー。ステータスを持たせて呼び出し側で判断できるようにする
class ApiError extends Error {
constructor(public status: number, public problem: Problem) {
super(problem.detail ?? problem.title);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
signal: AbortSignal.timeout(5000), // 5秒でタイムアウト。実務では必須
});
if (!res.ok) throw new ApiError(res.status, await res.json());
if (res.status === 204) return undefined as T;
return (await res.json()) as T; // ← ここは「信じているだけ」で型の保証はない
}
const created = await request<Todo>("/todos", {
method: "POST",
body: JSON.stringify({ title: "牛乳を買う" }),
});
console.log("作成:", created);
try {
await request<Todo>("/todos/does-not-exist");
} catch (e) {
if (e instanceof ApiError && e.status === 404) console.log("見つかりませんでした");
else throw e;
}
ここにRESTの弱点がある
クライアント側の type Todo はサーバーのコードをコピーして手で書いたものです。サーバー側で done を completed に改名しても、クライアントはコンパイルエラーにならず、実行時に静かに壊れます。この「サーバーとクライアントの型がずれる」問題を解決するのが、03章の OpenAPI と、04章以降の gRPC / Connect です。
ファイルを分ける:レイヤード構成
1ファイルで書いたコードは、機能が増えるとすぐ読めなくなります。実務では役割ごとに層(レイヤー)を分けるのが一般的です。
// service 層は HTTP を知らない。だから REST からも gRPC からも Connect からも再利用できる
export class NotFoundError extends Error {}
export class TodoService {
constructor(private repo: TodoRepository) {}
async complete(id: string): Promise<Todo> {
const todo = await this.repo.find(id);
if (!todo) throw new NotFoundError(`todo ${id} は存在しません`);
return this.repo.save({ ...todo, done: true });
}
}
// handler 側で NotFoundError → 404(REST)/ NOT_FOUND(gRPC)に変換する
ミドルへの視点:なぜ層を分けるのか
「きれいだから」ではありません。① service 層だけをテストできる(HTTPサーバーを立てずに済む)、② 通信方式を REST から Connect に変えても業務ロジックは無傷、③ DB を差し替えても影響が repository に閉じる。変更の影響範囲を狭めるのが目的です。後の章で REST と Connect を同じ service 層の上に並べる移行方法が出てきますが、それが可能なのはこの分離のおかげです。
理解度チェック
Q1. TypeScriptで型を書いているのに、なぜ zod での検証が必要?
TypeScriptの型はコンパイル時だけのもので、実行時には消えます。HTTPで届いたJSONが型どおりである保証はないので、実行時にチェックする必要があります。
Q2. fetch で 404 が返ってきたとき、何が起きる?
例外は投げられず、普通にレスポンスが返ります。res.ok が false になるので、自分でチェックしてエラー処理をする必要があります。
Q3. DELETE で存在しないIDが来たとき 404 ではなく 204 を返す設計の利点は?
冪等性が保たれ、リトライで2回目のDELETEが届いてもエラーにならない。「消えている状態」というゴールは達成されているため。(404を返す流儀もあり、チームで統一することが大事です)
03 実務のREST設計
02章のコードは「動く」APIです。本番で「壊れない・使いやすい・変えられる」APIにするには、あと何が必要でしょうか。レビューで指摘されがちな項目を順に見ていきます。
この章のゴール
- ページネーション・認証・冪等キー・バージョニングを設計に入れられる
- OpenAPI でAPIの「契約」を書き、型を自動生成できる
- RESTの限界を理解し、gRPCが必要になる場面が分かる
ページネーション:一覧は必ず分割する
「全件返す」一覧APIは、データが1万件になった日に障害を起こします。一覧APIには最初からページングを入れましょう。
| 方式 | リクエスト例 | 長所 | 短所 |
|---|---|---|---|
| オフセット | ?limit=20&offset=40 |
実装が簡単。「5ページ目へ」ができる | 件数が多いと遅い。途中でデータが増減すると重複・抜けが出る |
| カーソル | ?limit=20&cursor=eyJpZCI6... |
大量データでも速い。重複・抜けがない | 任意のページに飛べない |
{
"items": [ { "id": "...", "title": "..." } ],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTI4In0"
}
カーソルは「最後に返した要素の位置」をエンコードした文字列です。クライアントは中身を解釈せず、次のリクエストにそのまま渡すだけ。無限スクロールやバッチ処理にはカーソル方式が向いています。
認証と認可
認証(Authentication)は「あなたは誰?」、認可(Authorization)は「あなたはこれをしてよい?」です。REST では多くの場合、ログイン後に発行されたトークンを Authorization: Bearer <token> ヘッダーに載せて毎回送ります(ステートレスなので毎回)。
import type { Request, Response, NextFunction } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.header("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
const user = token ? verifyToken(token) : null; // JWT検証など(ライブラリを使う)
if (!user) {
res.status(401).json({ title: "Unauthorized", status: 401 });
return;
}
res.locals.user = user; // 後続のハンドラーで使えるようにする
next();
}
// 使い方: app.delete("/todos/:id", requireAuth, handler)
認可の漏れは重大事故になる
GET /todos/123 で、ログインしていれば他人のToDoも見えてしまうバグは非常に多い脆弱性です(IDOR / BOLA と呼ばれます)。「ログインしているか」だけでなく「そのリソースの持ち主か」を必ず確認してください。
冪等キー:POSTを安全にリトライする
01章の問題(POSTを再送すると二重登録になる)の実務的な解決策です。クライアントが操作ごとに一意なキーを発行してヘッダーで送り、サーバーは「このキーは処理済みか」を記録します。決済APIなどで広く使われている方式です。
バージョニング:APIは一度公開すると簡単に変えられない
APIの利用者(スマホアプリなど)は、あなたの都合で一斉にアップデートしてくれません。古いアプリが古い形式でリクエストを送り続けます。そこで「互換性を壊さない変更」と「壊す変更」を区別することが大切です。
-
壊さない変更(いつでもOK)
レスポンスにフィールドを追加する/任意のリクエストパラメータを追加する/新しいエンドポイントを追加する -
壊す変更(要注意)
フィールドの削除・改名・型変更/必須パラメータの追加/ステータスコードやエラー形式の変更
壊す変更がどうしても必要なときは、URLにバージョンを入れて(/v1/todos → /v2/todos)一定期間並行稼働させるのが最も分かりやすい方法です。クライアント側も「知らないフィールドが来ても無視する」実装にしておくと、サーバーの進化に強くなります。
その他のチェック項目
| 項目 | 要点 |
|---|---|
| CORS | ブラウザは、別ドメインのAPIを呼ぶとき事前確認(プリフライト OPTIONS)を行う。サーバーは Access-Control-Allow-Origin で許可するオリジンを明示する。* の多用は危険。 |
| レート制限 | 1ユーザーあたりの呼び出し回数を制限し、超えたら 429 と Retry-After を返す。 |
| キャッシュ |
ETag を返し、クライアントが If-None-Match で送ってきたら変化がなければ 304。通信量を大きく減らせる。 |
| 日時 | ISO 8601 形式(2026-09-28T10:00:00Z)でタイムゾーン込みで返す。 |
| 大きな整数ID | JavaScriptで精度が落ちるため、64bit整数のIDは文字列で返す。 |
| リクエストID | 全リクエストに一意なIDを振り、ログとレスポンスヘッダーに含める。問い合わせ対応や障害調査が劇的に楽になる。 |
OpenAPI:APIの「契約書」を書く
REST の「型がずれる」問題への標準的な答えが OpenAPI(旧 Swagger)です。APIの仕様を YAML で機械が読める形に書き、そこからドキュメント・クライアントの型・モックサーバーを自動生成します。
openapi: 3.1.0
info: { title: Todo API, version: 1.0.0 }
paths:
/todos/{id}:
get:
operationId: getTodo
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Todo" }
"404": { description: Not Found }
components:
schemas:
Todo:
type: object
required: [id, title, done]
properties:
id: { type: string }
title: { type: string, maxLength: 100 }
done: { type: boolean }
# OpenAPI から TypeScript の型を生成し、型安全な fetch クライアントを使う
npm i -D openapi-typescript
npm i openapi-fetch
npx openapi-typescript openapi.yaml -o src/api-types.ts
import createClient from "openapi-fetch";
import type { paths } from "./api-types";
const client = createClient<paths>({ baseUrl: "http://localhost:3000" });
// パスもパラメータもレスポンスも型チェックされる。typo はコンパイルエラーになる
const { data, error } = await client.GET("/todos/{id}", {
params: { path: { id: "7f3a" } },
});
if (data) console.log(data.title);
ミドルへの視点:スキーマファースト vs コードファースト
スキーマファーストは先に OpenAPI を書き、レビューで合意してから実装する方式。フロントとバックが並行で開発でき、契約が先に固まります。コードファーストはコード(デコレーターや zod 定義)から OpenAPI を生成する方式で、実装とのずれが起きにくい。どちらでも良いですが、「契約(スキーマ)が唯一の正しい情報源になっているか」が大事です。この発想をAPI方式そのものに組み込んだのが、次の gRPC です。
RESTの限界:なぜ gRPC が生まれたか
REST は人間にも読みやすく、ブラウザ・curl・CDN などあらゆる道具が対応している素晴らしい方式です。外部公開APIではいまも第一候補です。一方で、社内のサービス同士が大量に通信する場面では、次の点が問題になってきました。
- 契約が任意:OpenAPI は「書けば」型が得られますが、書かなくても動いてしまうので、実装とずれやすい。
- JSONが重い:1秒間に何万回も通信するとき、テキストの生成・解析とサイズが無視できないコストになる。
- 設計の揺れ:「この操作はPOST?PATCH?URLは?」という議論に毎回時間を使う。
- ストリーミングが苦手:サーバーから継続的にデータを流す、双方向にやりとりする、といった通信は別の仕組み(WebSocketなど)が必要。
理解度チェック
Q1. レスポンスの done を completed に改名したい。どうする?
いきなり改名すると古いクライアントが壊れます。まず completed を追加して両方返し、クライアントの移行が済んでから done を削除する(または新バージョンで削除する)という段階的な手順を踏みます。
Q2. 無限スクロールの一覧にはオフセットとカーソルどちらが向いている?
カーソル方式。途中で新しいデータが追加されても重複や抜けが起きず、大量データでも速いため。
Q3. ログイン済みユーザーが GET /todos/999(他人のToDo)を呼べてしまう。何が足りない?
認可チェック。認証(ログインしているか)だけでなく、そのリソースの所有者か・権限があるかを確認する必要があります。
次回(第2回)は、REST の限界を解決するために生まれた gRPC と Protocol Buffers を、実際のバイト列を見ながら解説します。