0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ゼロから実務まで①】Web通信の基礎とREST API設計・実装入門(TypeScript)

0
Posted at

この記事は、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 /createTodo
    POST /getTodo?id=1
    POST /deleteTodo
  • RESTらしい例(名詞+メソッド)
    POST /todos
    GET /todos/1
    DELETE /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ファイルで全体を書きます。コメントを読みながら、上から順に追ってください。

src/rest-server.ts
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"));

コードのポイント

  1. 入力は必ず検証する
    TypeScriptの型はコンパイル時にしか存在しません。実行時に外から届くデータには型の保証が一切ないので、zod のようなライブラリで形をチェックします。これを忘れるとバグやセキュリティ事故の原因になります。
  2. エラーの形を1つに揃える
    problem() 関数で、全てのエラーを同じ形(RFC 9457 という標準形式)で返しています。クライアント側は「エラーなら detail を表示する」と1通りの処理で済みます。
  3. ステータスコードを使い分ける
    作成は 201+Location、削除は 204、見つからなければ 404。01章の表どおりです。
  4. 内部エラーの詳細は隠す
    最後のエラーハンドラーはスタックトレースをログにだけ出し、クライアントには「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)を自分で確認する必要があります。

src/rest-client.ts
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ファイルで書いたコードは、機能が増えるとすぐ読めなくなります。実務では役割ごとに層(レイヤー)を分けるのが一般的です。

src/todo/service.ts
// 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.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
src/typed-client.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 を、実際のバイト列を見ながら解説します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?