Lecture 1Express入門 — 最初のAPIサーバーを起動する

12:00

Express入門 — 最初のAPIサーバーを起動する

APIサーバーとは何か

Webアプリケーションやモバイルアプリを使うとき、画面の裏側では「APIサーバー」がデータのやり取りを担っています。たとえばブックマークアプリで「保存」ボタンを押すと、アプリはAPIサーバーにリクエストを送り、サーバーがデータベースに保存して結果を返します。

APIとは Application Programming Interface の略で、プログラム同士が通信するための約束事です。中でも REST API は、HTTPプロトコル(ブラウザがWebサイトを表示するときに使う通信の仕組み)を使ってデータを操作する設計スタイルで、現在最も広く使われています。

用語 意味
API プログラム間の通信インターフェース Twitter API、Google Maps API
REST HTTPベースのAPI設計スタイル GET /users で一覧取得
エンドポイント APIの個別URL /api/bookmarks
Express Node.js用のWebフレームワーク 本講座で使用

この講座では Express.js を使い、Node.js上で動くREST APIサーバーをゼロから構築します。テーマは「ブックマーク管理API」です。URLをカテゴリ別に保存・取得・更新・削除できるAPIを、Claude Codeの支援を受けながら段階的に作り上げていきます。

プロジェクトの初期化

まず、プロジェクトのディレクトリを作成し、Node.jsプロジェクトとして初期化します。Claude Codeに依頼してみましょう。

> ブックマーク管理APIのNode.jsプロジェクトを作って
  ディレクトリ名は bookmark-api
  npm init して package.json を生成
  express  nodemon をインストールして

Claude Codeが生成するコマンドは以下のようになります。

mkdir bookmark-api
cd bookmark-api
npm init -y
npm install express
npm install --save-dev nodemon

npm init -y は質問をすべてデフォルトで回答して package.json を作成します。package.json はプロジェクトの設定ファイルで、名前、バージョン、依存パッケージなどの情報が記録されます。

生成された package.jsonscripts セクションを修正して、開発用の起動コマンドを追加します。

{
  "name": "bookmark-api",
  "version": "1.0.0",
  "description": "ブックマーク管理REST API",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  },
  "dependencies": {
    "express": "^4.21.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

nodemon は、ファイルを保存するたびに自動でサーバーを再起動してくれる開発用ツールです。npm run dev で起動すると、コードを変更するたびに手動で再起動する必要がなくなります。

最初のExpressサーバー

次に、最もシンプルなExpressサーバーを作ります。Claude Codeに依頼しましょう。

> src/index.js にExpressサーバーを作って。
  ポート3000で起動して、
  GET / にアクセスしたら
  { message: "Bookmark API is running" } を返すようにして。

Claude Codeが生成するコードは以下のようになります。

// src/index.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

// JSON形式のリクエストボディを解析するミドルウェア
app.use(express.json());

// ルートエンドポイント
app.get('/', (req, res) => {
  res.json({ message: 'Bookmark API is running' });
});

// サーバー起動
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

このコードを1行ずつ読み解きましょう。

  1. const express = require('express') — expressパッケージを読み込む
  2. const app = express() — Expressアプリケーションのインスタンスを作成
  3. app.use(express.json()) — JSONリクエストボディの解析を有効にする(後の講義で詳しく解説)
  4. app.get('/', ...) — GETリクエストで / にアクセスがあったときの処理を定義
  5. res.json(...) — JSON形式でレスポンスを返す
  6. app.listen(PORT, ...) — 指定ポートでサーバーを起動

req はリクエスト(クライアントからの要求)、res はレスポンス(サーバーからの返答)を表すオブジェクトです。この2つはExpressの全てのルートハンドラで使います。

サーバーの起動とテスト

サーバーを起動してみましょう。

npm run dev

ターミナルに Server is running on http://localhost:3000 と表示されれば成功です。

ブラウザでテスト

ブラウザで http://localhost:3000 にアクセスしてください。{"message":"Bookmark API is running"} というJSONが表示されます。

curlコマンドでテスト

APIの開発では、ブラウザよりもコマンドラインの curl を使うことが多いです。新しいターミナルを開いて以下を実行します。

curl http://localhost:3000

レスポンスが表示されます。

{"message":"Bookmark API is running"}

curl-v オプションを付けると、HTTPヘッダーなどの詳細情報が確認できます。

curl -v http://localhost:3000

ステータスコード 200 OKContent-Type: application/json といった情報が表示されます。これらはAPIの動作確認やデバッグに役立ちます。

ブックマーク用エンドポイントの追加

もう1つエンドポイントを追加してみましょう。

> /api/bookmarks にGETリクエストしたら、
  サンプルのブックマーク配列を返すエンドポイントを追加して。
  レスポンス形式は { data: [...], error: null }
// サンプルデータ
const sampleBookmarks = [
  { id: 1, title: 'Express公式ドキュメント', url: 'https://expressjs.com', category: '技術' },
  { id: 2, title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: '技術' },
];

// ブックマーク一覧取得
app.get('/api/bookmarks', (req, res) => {
  res.json({ data: sampleBookmarks, error: null });
});
curl http://localhost:3000/api/bookmarks

nodemonが動いているので、コードを保存した瞬間にサーバーが再起動され、すぐに新しいエンドポイントが使えるようになります。

プロジェクトのディレクトリ構成

この講座を通じて、以下のディレクトリ構成を段階的に作り上げていきます。

bookmark-api/
  src/
    index.js           サーバーのエントリーポイント今回作成
    routes/             ルート定義第2回
    middleware/         ミドルウェア第3回
    db/                 データベース関連第5回
  tests/                テストファイル第9回
  package.json
  .env                  環境変数第10回

最初から全てを作る必要はありません。講義ごとに1つずつファイルを追加していく形で進めます。

実践ワーク

  1. bookmark-api ディレクトリを作成し、npm init -y でプロジェクトを初期化する
  2. expressnodemon をインストールする
  3. src/index.js を作成し、GETリクエストでJSONを返すサーバーを起動する
  4. ブラウザと curl の両方でレスポンスを確認する
  5. /api/bookmarks エンドポイントを追加し、サンプルデータを返すようにする
  6. nodemon によるホットリロードが動作していることを確認する(コードを編集して保存し、自動再起動を確認)

まとめと次回の準備

今回のポイント: - Express.jsはNode.jsの軽量Webフレームワークで、APIサーバー構築に最適 - npm init でプロジェクト初期化、expressnodemon をインストール - app.get(path, handler) でGETリクエストのハンドラを登録 - res.json() でJSON形式のレスポンスを返す - curl コマンドでAPIのテストができる

次回: ルーティングを学びます。GET以外のHTTPメソッド(POST、PUT、DELETE)を使い、URLの設計パターンを理解します。

参考文献: - Express.js公式ドキュメント(https://expressjs.com/ja/) - Node.js公式ドキュメント(https://nodejs.org/ja/docs/) - MDN Web Docs「HTTP の概要」(https://developer.mozilla.org/ja/docs/Web/HTTP/Overview)

Lecture 2ルーティング — URLとHTTPメソッドを設計する

12:00

ルーティング — URLとHTTPメソッドを設計する

RESTの基本原則

前回作ったExpressサーバーでは、GET /GET /api/bookmarks の2つのエンドポイントを用意しました。しかし実際のAPIでは、データの取得だけでなく「作成」「更新」「削除」も必要です。これらの操作をどうURLに割り当てるかを決めるのが REST(Representational State Transfer) の設計原則です。

RESTでは、URLは「リソース(データの種類)」を表し、HTTPメソッドが「操作」を表します。

HTTPメソッド 操作 URL例 意味
GET 取得 /api/bookmarks ブックマーク一覧を取得
GET 取得 /api/bookmarks/1 ID=1のブックマークを取得
POST 作成 /api/bookmarks 新しいブックマークを作成
PUT 更新 /api/bookmarks/1 ID=1のブックマークを更新
DELETE 削除 /api/bookmarks/1 ID=1のブックマークを削除

重要なポイントは、URLは名詞(bookmarks)で、動詞(create、deleteなど)は含めない ということです。操作の種類はHTTPメソッドで表現します。

良い設計:  DELETE /api/bookmarks/1
悪い設計:  GET /api/bookmarks/delete/1

ルートパラメータとクエリ文字列

URLで個別のリソースを特定するために、2つの仕組みがあります。

ルートパラメータ(Path Parameters)

URLの一部にコロン(:)で定義する動的な値です。特定のリソースを指すときに使います。

> Expressでルートパラメータを使って、
  特定のブックマークを取得するエンドポイントを作って。
  GET /api/bookmarks/:id でIDを受け取る。
// ルートパラメータの例
app.get('/api/bookmarks/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const bookmark = bookmarks.find(b => b.id === id);

  if (!bookmark) {
    return res.status(404).json({
      data: null,
      error: 'ブックマークが見つかりません'
    });
  }

  res.json({ data: bookmark, error: null });
});

req.params.id:id に対応する値を取得できます。/api/bookmarks/5 にアクセスすると req.params.id"5" になります(文字列なので parseInt で数値に変換します)。

クエリ文字列(Query Parameters)

URLの末尾に ?key=value の形式で付与するパラメータです。検索条件やフィルタリングに使います。

> クエリ文字列でブックマークをカテゴリで絞り込む機能を追加して。
  GET /api/bookmarks?category=技術 の形式で。
// クエリ文字列の例
app.get('/api/bookmarks', (req, res) => {
  let result = bookmarks;

  // カテゴリで絞り込み
  if (req.query.category) {
    result = result.filter(b => b.category === req.query.category);
  }

  // タイトルで検索
  if (req.query.search) {
    result = result.filter(b =>
      b.title.toLowerCase().includes(req.query.search.toLowerCase())
    );
  }

  res.json({ data: result, error: null });
});

req.query でクエリパラメータにアクセスできます。/api/bookmarks?category=技術&search=express なら req.query.category"技術"req.query.search"express" になります。

使い分けの基準: - ルートパラメータ: 必須のリソース識別子(ID) - クエリ文字列: 任意のフィルタ、検索、ページネーション

express.Routerでルートを分離する

全てのルートを index.js に書くと、ファイルが肥大化して管理が難しくなります。express.Router を使ってルートを別ファイルに分離しましょう。

> ブックマークのルート定義を src/routes/bookmarks.js に分離して。
  express.Routerを使って、
  GET一覧、GET個別、POST作成、PUT更新、DELETE削除の
  5つのエンドポイントを定義して。

Claude Codeが生成するコードは以下のようになります。

// src/routes/bookmarks.js
const express = require('express');
const router = express.Router();

// 仮のデータ(後でDBに置き換え)
let bookmarks = [
  { id: 1, title: 'Express公式', url: 'https://expressjs.com', category: '技術' },
  { id: 2, title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: '技術' },
];
let nextId = 3;

// 一覧取得
router.get('/', (req, res) => {
  let result = bookmarks;
  if (req.query.category) {
    result = result.filter(b => b.category === req.query.category);
  }
  res.json({ data: result, error: null });
});

// 個別取得
router.get('/:id', (req, res) => {
  const bookmark = bookmarks.find(b => b.id === parseInt(req.params.id));
  if (!bookmark) {
    return res.status(404).json({ data: null, error: 'ブックマークが見つかりません' });
  }
  res.json({ data: bookmark, error: null });
});

// 新規作成
router.post('/', (req, res) => {
  const { title, url, category } = req.body;
  const bookmark = { id: nextId++, title, url, category: category || '未分類' };
  bookmarks.push(bookmark);
  res.status(201).json({ data: bookmark, error: null });
});

// 更新
router.put('/:id', (req, res) => {
  const index = bookmarks.findIndex(b => b.id === parseInt(req.params.id));
  if (index === -1) {
    return res.status(404).json({ data: null, error: 'ブックマークが見つかりません' });
  }
  const { title, url, category } = req.body;
  bookmarks[index] = { ...bookmarks[index], title, url, category };
  res.json({ data: bookmarks[index], error: null });
});

// 削除
router.delete('/:id', (req, res) => {
  const index = bookmarks.findIndex(b => b.id === parseInt(req.params.id));
  if (index === -1) {
    return res.status(404).json({ data: null, error: 'ブックマークが見つかりません' });
  }
  const deleted = bookmarks.splice(index, 1)[0];
  res.json({ data: deleted, error: null });
});

module.exports = router;

index.js でこのルーターをマウントします。

// src/index.js
const express = require('express');
const bookmarkRoutes = require('./routes/bookmarks');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

// ルートのマウント
app.get('/', (req, res) => {
  res.json({ message: 'Bookmark API is running' });
});
app.use('/api/bookmarks', bookmarkRoutes);

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

app.use('/api/bookmarks', bookmarkRoutes) により、bookmarkRoutes 内の router.get('/')/api/bookmarks/ に、router.get('/:id')/api/bookmarks/:id にマッピングされます。

curlで全エンドポイントをテストする

全5つのエンドポイントを curl でテストしましょう。

# 一覧取得
curl http://localhost:3000/api/bookmarks

# カテゴリ検索
curl "http://localhost:3000/api/bookmarks?category=技術"

# 個別取得
curl http://localhost:3000/api/bookmarks/1

# 新規作成(POSTリクエスト)
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title":"GitHub","url":"https://github.com","category":"開発ツール"}'

# 更新(PUTリクエスト)
curl -X PUT http://localhost:3000/api/bookmarks/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Express.js公式ドキュメント","url":"https://expressjs.com","category":"技術"}'

# 削除(DELETEリクエスト)
curl -X DELETE http://localhost:3000/api/bookmarks/2

-X POST でHTTPメソッドを指定し、-H でヘッダーを追加し、-d でリクエストボディを送信します。POSTとPUTでは Content-Type: application/json ヘッダーが必須です。これがないと express.json() ミドルウェアがボディを解析してくれません。

HTTPステータスコード

レスポンスには適切なステータスコードを返すことが重要です。

コード 意味 使いどころ
200 OK 取得・更新・削除の成功
201 Created 新規作成の成功
400 Bad Request リクエストのデータが不正
404 Not Found リソースが見つからない
500 Internal Server Error サーバー内部エラー

res.status(201).json(...) のように、status() メソッドでステータスコードを設定してから json() でレスポンスを返します。

実践ワーク

  1. src/routes/bookmarks.js を作成し、5つのCRUDエンドポイントを実装する
  2. src/index.js でルーターをマウントする
  3. curl で全エンドポイント(GET一覧、GET個別、POST、PUT、DELETE)をテストする
  4. クエリ文字列 ?category=技術 で絞り込みが動作することを確認する
  5. 存在しないIDへのアクセスで404が返ることを確認する
  6. (発展)カテゴリ一覧を返す GET /api/categories エンドポイントを追加する

まとめと次回の準備

今回のポイント: - RESTではURLがリソース(名詞)、HTTPメソッドが操作(動詞)を表す - ルートパラメータ(:id)は必須の識別子、クエリ文字列は任意のフィルタ - express.Router でルートをファイル分離し、app.use() でマウント - 適切なHTTPステータスコード(200, 201, 404)を返す

次回: ミドルウェアを学びます。リクエスト処理の流れを制御し、ログ出力やCORS設定など、APIの基盤機能を追加します。

参考文献: - Express.js「ルーティング」(https://expressjs.com/ja/guide/routing.html) - MDN Web Docs「HTTP リクエストメソッド」(https://developer.mozilla.org/ja/docs/Web/HTTP/Methods) - Roy Fielding "Architectural Styles and the Design of Network-based Software Architectures"(REST の原論文, 2000年)

Lecture 3ミドルウェア — リクエスト処理の仕組みを理解する

12:00

ミドルウェア — リクエスト処理の仕組みを理解する

ミドルウェアとは何か

Expressの最も強力な概念が ミドルウェア(middleware) です。ミドルウェアとは、リクエストがルートハンドラに到達する前(または後)に実行される関数のことです。

イメージとしては、空港のセキュリティチェックに似ています。搭乗ゲート(ルートハンドラ)に到達する前に、パスポート確認、手荷物検査、ボディチェックといった複数のチェックポイント(ミドルウェア)を通過する必要があります。

リクエスト → [ミドルウェア1] → [ミドルウェア2] → [ルートハンドラ] → レスポンス

ミドルウェア関数は3つの引数を取ります。

function myMiddleware(req, res, next) {
  // req: リクエストオブジェクト
  // res: レスポンスオブジェクト
  // next: 次のミドルウェアに処理を渡す関数
  console.log('ミドルウェアが実行されました');
  next(); // 次に進む(これを呼ばないとリクエストが止まる)
}

next() を呼ばないと、リクエストはそこで止まり、クライアントにレスポンスが返りません。 これはミドルウェアを書くときの最も重要なルールです。

組み込みミドルウェア

Expressには最初からいくつかのミドルウェアが用意されています。

express.json()

前回から既に使っている express.json() は、リクエストボディのJSON文字列をJavaScriptオブジェクトに変換するミドルウェアです。

app.use(express.json());

このミドルウェアがないと、POSTやPUTリクエストで送られたJSONデータを req.body で取得できません。

# express.json() がある場合
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title":"テスト"}'
# → req.body は { title: "テスト" }

# express.json() がない場合
# → req.body は undefined

express.urlencoded()

HTMLフォームから送られるデータ(application/x-www-form-urlencoded 形式)を解析するミドルウェアです。REST APIではJSONが主流ですが、フォーム対応が必要な場合に使います。

app.use(express.urlencoded({ extended: true }));

express.static()

静的ファイル(HTML、CSS、画像など)を配信するミドルウェアです。API開発ではあまり使いませんが、ドキュメントページの配信などに役立ちます。

app.use(express.static('public'));

外部ミドルウェアの導入

Express単体にない機能は、外部パッケージで追加できます。Claude Codeに依頼してセットアップしましょう。

> ブックマーク管理APIにミドルウェアを追加して。
  cors — フロントエンドからのアクセスを許可、
  morgan — リクエストのログを出力。
  それぞれnpmでインストールして、app.use()で適用して。
npm install cors morgan
// src/index.js
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const bookmarkRoutes = require('./routes/bookmarks');

const app = express();
const PORT = process.env.PORT || 3000;

// ミドルウェアの適用(順序が重要!)
app.use(cors());              // CORS許可
app.use(morgan('dev'));       // リクエストログ
app.use(express.json());     // JSONボディ解析

// ルート
app.get('/', (req, res) => {
  res.json({ message: 'Bookmark API is running' });
});
app.use('/api/bookmarks', bookmarkRoutes);

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

CORS(Cross-Origin Resource Sharing)

ブラウザには「同一オリジンポリシー」というセキュリティルールがあり、異なるオリジン(ドメイン)からのリクエストをブロックします。例えば、http://localhost:5173(フロントエンド)から http://localhost:3000(API)へのリクエストは、デフォルトではブロックされます。

cors() ミドルウェアを使うと、レスポンスに Access-Control-Allow-Origin ヘッダーが追加され、異なるオリジンからのアクセスが許可されます。

// 全てのオリジンを許可(開発時)
app.use(cors());

// 特定のオリジンのみ許可(本番推奨)
app.use(cors({
  origin: 'https://my-frontend.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));

Morgan(HTTPリクエストロガー)

morgan はHTTPリクエストの情報をコンソールに出力するロガーです。

GET /api/bookmarks 200 3.456 ms - 128
POST /api/bookmarks 201 1.234 ms - 95
GET /api/bookmarks/999 404 0.567 ms - 52

'dev' フォーマットでは、メソッド、URL、ステータスコード、応答時間、レスポンスサイズが表示されます。ステータスコードに応じて色分けされるので、エラーの発見が容易です。

カスタムミドルウェアを作る

実際のAPIでは、プロジェクト固有のミドルウェアが必要になります。Claude Codeに作ってもらいましょう。

> src/middleware/requestLogger.js に
  カスタムのリクエストログミドルウェアを作って。
  タイムスタンプ、メソッド、URL、レスポンス時間を記録して。
// src/middleware/requestLogger.js
function requestLogger(req, res, next) {
  const start = Date.now();

  // レスポンスが送信された後に実行
  res.on('finish', () => {
    const duration = Date.now() - start;
    const timestamp = new Date().toISOString();
    console.log(
      `[${timestamp}] ${req.method} ${req.originalUrl} → ${res.statusCode} (${duration}ms)`
    );
  });

  next();
}

module.exports = requestLogger;

res.on('finish', ...) はレスポンスが送信完了したときに実行されるイベントリスナーです。これにより、リクエストからレスポンスまでの正確な時間を計測できます。

リクエストIDミドルウェア

各リクエストに一意のIDを付与するミドルウェアも便利です。

// src/middleware/requestId.js
const crypto = require('crypto');

function requestId(req, res, next) {
  req.id = crypto.randomUUID();
  res.setHeader('X-Request-Id', req.id);
  next();
}

module.exports = requestId;

このIDはログに記録しておくと、問題が発生した際にリクエストを追跡するのに役立ちます。

ミドルウェアの適用範囲

ミドルウェアはグローバル、ルーター単位、ルート単位で適用できます。

// グローバル(全ルートに適用)
app.use(morgan('dev'));

// ルーター単位(特定のパスのルートにだけ適用)
app.use('/api/bookmarks', authMiddleware, bookmarkRoutes);

// ルート単位(特定の1ルートにだけ適用)
router.get('/:id', validateId, (req, res) => {
  // validateId ミドルウェアを通過した場合のみ実行
});

ミドルウェアの実行順序は app.use() の記述順 です。上から下へ順番に実行されるため、ログ → CORS → ボディ解析 → 認証 → ルートハンドラ のように、依存関係を考慮して配置します。

実践ワーク

  1. corsmorgan をインストールし、src/index.js に適用する
  2. morgan('dev') でリクエストログが出力されることを確認する
  3. src/middleware/requestLogger.js にカスタムロガーを作成する
  4. リクエストIDミドルウェアを作成し、レスポンスヘッダーにIDが含まれることを curl -v で確認する
  5. next() を呼ばないミドルウェアを意図的に作り、リクエストがハングする様子を観察する
  6. (発展)レスポンスタイムを集計し、平均応答時間を計算するミドルウェアを作る

まとめと次回の準備

今回のポイント: - ミドルウェアはリクエストとレスポンスの間で処理を行う関数 - next() を呼ばないとリクエストが止まる - express.json() / cors() / morgan() が基本の3つ - express.Router と組み合わせてスコープを制御できる - ミドルウェアの実行順序は app.use() の記述順

次回: CRUD操作を本格的に実装します。ブックマークの作成・取得・更新・削除を、適切なステータスコードとレスポンス形式で完成させます。

参考文献: - Express.js「ミドルウェアの使用」(https://expressjs.com/ja/guide/using-middleware.html) - cors パッケージ(https://www.npmjs.com/package/cors) - morgan パッケージ(https://www.npmjs.com/package/morgan)

Lecture 4CRUD操作 — データの作成・取得・更新・削除

12:00

CRUD操作 — データの作成・取得・更新・削除

CRUDとは

CRUDは Create(作成)・Read(取得)・Update(更新)・Delete(削除) の頭文字で、データ操作の4つの基本操作を表します。ほぼ全てのWebアプリケーションは、この4つの操作の組み合わせで成り立っています。

CRUD操作 HTTPメソッド SQLコマンド 説明
Create POST INSERT データの新規作成
Read GET SELECT データの取得
Update PUT / PATCH UPDATE データの更新
Delete DELETE DELETE データの削除

前回のルーティング講義で5つのエンドポイントを仮実装しましたが、今回はレスポンス形式を統一し、バリデーションの基礎を加え、実用的なCRUD操作に仕上げます。

レスポンス形式の統一

APIのレスポンスは一貫した形式にすることが重要です。クライアント側で「成功か失敗か」を判定しやすくなります。Claude Codeに統一フォーマットを設計してもらいましょう。

> APIレスポンスの統一フォーマットを設計して。
  成功時は { data: ..., error: null }、
  エラー時は { data: null, error: "メッセージ" } の形式。
  ヘルパー関数として src/utils/response.js に切り出して。
// src/utils/response.js

/**
 * 成功レスポンスを返す
 * @param {object} res - Expressのレスポンスオブジェクト
 * @param {*} data - レスポンスデータ
 * @param {number} statusCode - HTTPステータスコード(デフォルト: 200)
 */
function success(res, data, statusCode = 200) {
  return res.status(statusCode).json({
    data,
    error: null,
  });
}

/**
 * エラーレスポンスを返す
 * @param {object} res - Expressのレスポンスオブジェクト
 * @param {string} message - エラーメッセージ
 * @param {number} statusCode - HTTPステータスコード(デフォルト: 400)
 */
function error(res, message, statusCode = 400) {
  return res.status(statusCode).json({
    data: null,
    error: message,
  });
}

module.exports = { success, error };

このヘルパーを使うことで、ルートハンドラのコードが簡潔になり、レスポンス形式の一貫性が保証されます。

インメモリデータストアの実装

データベース接続は次回の講義で扱うので、今回はメモリ上の配列でデータを管理します。Claude Codeにデータストアを設計してもらいましょう。

> src/data/bookmarkStore.js にインメモリのデータストアを作って。
  bookmarks配列と、CRUD操作の関数を定義して。
  IDは自動採番、作成日時と更新日時も記録して。
// src/data/bookmarkStore.js
let bookmarks = [
  {
    id: 1,
    title: 'Express公式ドキュメント',
    url: 'https://expressjs.com',
    category: '技術',
    description: 'Express.jsの公式ドキュメント',
    createdAt: '2025-01-01T00:00:00.000Z',
    updatedAt: '2025-01-01T00:00:00.000Z',
  },
  {
    id: 2,
    title: 'MDN Web Docs',
    url: 'https://developer.mozilla.org',
    category: '技術',
    description: 'Mozilla Developer Network',
    createdAt: '2025-01-02T00:00:00.000Z',
    updatedAt: '2025-01-02T00:00:00.000Z',
  },
];
let nextId = 3;

// 全件取得(フィルタ対応)
function findAll(filters = {}) {
  let result = [...bookmarks];
  if (filters.category) {
    result = result.filter(b => b.category === filters.category);
  }
  if (filters.search) {
    const keyword = filters.search.toLowerCase();
    result = result.filter(b =>
      b.title.toLowerCase().includes(keyword) ||
      b.description.toLowerCase().includes(keyword)
    );
  }
  return result;
}

// ID指定で取得
function findById(id) {
  return bookmarks.find(b => b.id === id) || null;
}

// 新規作成
function create(data) {
  const now = new Date().toISOString();
  const bookmark = {
    id: nextId++,
    title: data.title,
    url: data.url,
    category: data.category || '未分類',
    description: data.description || '',
    createdAt: now,
    updatedAt: now,
  };
  bookmarks.push(bookmark);
  return bookmark;
}

// 更新
function update(id, data) {
  const index = bookmarks.findIndex(b => b.id === id);
  if (index === -1) return null;

  bookmarks[index] = {
    ...bookmarks[index],
    ...data,
    id, // IDは変更不可
    updatedAt: new Date().toISOString(),
  };
  return bookmarks[index];
}

// 削除
function remove(id) {
  const index = bookmarks.findIndex(b => b.id === id);
  if (index === -1) return null;
  return bookmarks.splice(index, 1)[0];
}

module.exports = { findAll, findById, create, update, remove };

データストアをルートハンドラから分離することで、後でデータベースに移行するときにルートの変更が最小限で済みます。

ルートハンドラの完成版

レスポンスヘルパーとデータストアを使い、ルートハンドラを書き直します。

> src/routes/bookmarks.js を書き直して。
  bookmarkStore と response ヘルパーを使って、
  5つのCRUDエンドポイントを実装して。
  基本的なバリデーション(必須項目チェック)も入れて。
// src/routes/bookmarks.js
const express = require('express');
const router = express.Router();
const store = require('../data/bookmarkStore');
const { success, error } = require('../utils/response');

// GET /api/bookmarks — 一覧取得
router.get('/', (req, res) => {
  const filters = {
    category: req.query.category,
    search: req.query.search,
  };
  const bookmarks = store.findAll(filters);
  success(res, bookmarks);
});

// GET /api/bookmarks/:id — 個別取得
router.get('/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) {
    return error(res, 'IDは数値で指定してください', 400);
  }

  const bookmark = store.findById(id);
  if (!bookmark) {
    return error(res, 'ブックマークが見つかりません', 404);
  }
  success(res, bookmark);
});

// POST /api/bookmarks — 新規作成
router.post('/', (req, res) => {
  const { title, url } = req.body;

  // 必須項目のバリデーション
  if (!title || !title.trim()) {
    return error(res, 'タイトルは必須です', 400);
  }
  if (!url || !url.trim()) {
    return error(res, 'URLは必須です', 400);
  }

  // URL形式の簡易チェック
  try {
    new URL(url);
  } catch {
    return error(res, '有効なURLを入力してください', 400);
  }

  const bookmark = store.create(req.body);
  success(res, bookmark, 201);
});

// PUT /api/bookmarks/:id — 更新
router.put('/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) {
    return error(res, 'IDは数値で指定してください', 400);
  }

  const { title, url } = req.body;
  if (title !== undefined && !title.trim()) {
    return error(res, 'タイトルは空にできません', 400);
  }
  if (url !== undefined) {
    try {
      new URL(url);
    } catch {
      return error(res, '有効なURLを入力してください', 400);
    }
  }

  const bookmark = store.update(id, req.body);
  if (!bookmark) {
    return error(res, 'ブックマークが見つかりません', 404);
  }
  success(res, bookmark);
});

// DELETE /api/bookmarks/:id — 削除
router.delete('/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) {
    return error(res, 'IDは数値で指定してください', 400);
  }

  const deleted = store.remove(id);
  if (!deleted) {
    return error(res, 'ブックマークが見つかりません', 404);
  }
  success(res, deleted);
});

module.exports = router;

PUTとPATCHの違い

HTTPにはリソース更新用のメソッドが2つあります。

メソッド 意味 送るデータ
PUT リソースの完全置換 全フィールドを含むオブジェクト
PATCH リソースの部分更新 変更したいフィールドのみ

厳密にはPUTはリソース全体を送る必要がありますが、実際のAPI開発では PUTで部分更新を受け付ける 実装が多いです。本講座でもその方針に従い、PUTで部分更新をサポートしています。

curlで全操作をテストする

全てのCRUD操作をテストして、期待通りのレスポンスが返ることを確認します。

# 1. 一覧取得
curl http://localhost:3000/api/bookmarks | jq

# 2. 新規作成
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title":"GitHub","url":"https://github.com","category":"開発ツール","description":"コード管理プラットフォーム"}' | jq

# 3. 個別取得(ID=3が作成されたはず)
curl http://localhost:3000/api/bookmarks/3 | jq

# 4. 更新
curl -X PUT http://localhost:3000/api/bookmarks/3 \
  -H "Content-Type: application/json" \
  -d '{"title":"GitHub - コード管理"}' | jq

# 5. 削除
curl -X DELETE http://localhost:3000/api/bookmarks/3 | jq

# 6. バリデーションエラー(タイトルなし)
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' | jq

# 7. 存在しないID
curl http://localhost:3000/api/bookmarks/999 | jq

| jq を付けるとJSONが整形表示されます(jq コマンドがインストール済みの場合)。

実践ワーク

  1. src/utils/response.js を作成し、success()error() ヘルパーを実装する
  2. src/data/bookmarkStore.js を作成し、CRUD関数を実装する
  3. src/routes/bookmarks.js をヘルパーとストアを使って書き直す
  4. 必須項目(title、url)のバリデーションが動作することを確認する
  5. URL形式のバリデーションが動作することを確認する
  6. 全5エンドポイントを curl でテストし、正しいステータスコードが返ることを確認する
  7. (発展)ページネーション機能を追加する(?page=1&limit=10

まとめと次回の準備

今回のポイント: - CRUDはCreate/Read/Update/Deleteの4操作で、全てのデータ操作の基盤 - レスポンス形式を { data, error } に統一すると、クライアントの実装が簡単になる - データストアをルートから分離すると、後でDBに移行しやすい - 基本的なバリデーション(必須チェック、URL形式チェック)で不正データを防ぐ

次回: インメモリデータをSQLiteデータベースに移行します。サーバーを再起動してもデータが消えない、永続的なストレージを導入します。

参考文献: - Express.js「レスポンスメソッド」(https://expressjs.com/ja/guide/routing.html) - MDN Web Docs「HTTP レスポンスステータスコード」(https://developer.mozilla.org/ja/docs/Web/HTTP/Status) - Microsoft REST API ガイドライン(https://github.com/microsoft/api-guidelines)

Lecture 5データベース接続 — SQLiteでデータを永続化する

12:00

データベース接続 — SQLiteでデータを永続化する

なぜデータベースが必要なのか

前回までのブックマーク管理APIでは、データをJavaScriptの配列(メモリ上)に保存していました。この方法にはサーバーを再起動するとデータが消えるという致命的な問題があります。開発中にnodemonがファイル変更を検知して自動再起動するたびに、追加したブックマークが全て消えてしまいます。

データベースを使えば、データがディスク上のファイルに永続化されるため、サーバーの再起動やクラッシュに影響されません。

SQLiteを選ぶ理由

データベースには多くの選択肢がありますが、本講座では SQLite を使います。

データベース 種類 特徴
SQLite ファイルベース インストール不要、設定不要、1ファイルで完結
PostgreSQL サーバー型 高機能だがセットアップが複雑
MySQL サーバー型 広く使われるがサーバー起動が必要
MongoDB ドキュメント型 NoSQLだがスキーマ設計が異なる

SQLiteの最大の利点は サーバーが不要 なことです。データベース全体が1つのファイル(database.sqlite)に格納され、追加のプロセスを起動する必要がありません。学習段階ではこのシンプルさが最適です。

本番環境では PostgreSQL や MySQL を使うことが多いですが、SQLで操作する基本的な考え方は共通です。SQLiteで学んだ知識はそのまま他のデータベースにも応用できます。

better-sqlite3のインストールとセットアップ

Node.jsからSQLiteを操作するには better-sqlite3 パッケージを使います。Claude Codeにセットアップを依頼しましょう。

> ブックマーク管理APIにSQLiteデータベースを追加して。
  better-sqlite3をインストールして、
  src/db/database.js にDB初期化コードを作って。
  bookmarksテーブルを作成するマイグレーションも書いて。
npm install better-sqlite3
// src/db/database.js
const Database = require('better-sqlite3');
const path = require('path');

// データベースファイルのパス
const DB_PATH = path.join(__dirname, '../../data/bookmarks.sqlite');

// データベース接続
const db = new Database(DB_PATH);

// WALモードを有効化(パフォーマンス向上)
db.pragma('journal_mode = WAL');

// テーブル作成(存在しなければ)
db.exec(`
  CREATE TABLE IF NOT EXISTS bookmarks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    url TEXT NOT NULL,
    category TEXT DEFAULT '未分類',
    description TEXT DEFAULT '',
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
  )
`);

module.exports = db;

コードの解説:

  • new Database(DB_PATH) でSQLiteファイルに接続します。ファイルが存在しなければ自動で作成されます
  • pragma('journal_mode = WAL') はWAL(Write-Ahead Logging)モードを有効にし、読み取りと書き込みの並行処理性能を向上させます
  • CREATE TABLE IF NOT EXISTS はテーブルが未作成の場合のみ作成する安全なSQL文です
  • AUTOINCREMENT でIDが自動的に採番されます
  • datetime('now') はSQLiteの日時関数で、現在のUTC時刻を文字列として記録します

データストアのDB版を実装する

前回作った bookmarkStore.js(インメモリ版)をデータベース版に置き換えます。

> src/data/bookmarkStore.js をSQLiteベースに書き換えて。
  better-sqlite3のprepared statementsを使って、
  findAll, findById, create, update, remove の
  5つの関数を実装して。
// src/data/bookmarkStore.js(DB版)
const db = require('../db/database');

// 全件取得(フィルタ対応)
function findAll(filters = {}) {
  let sql = 'SELECT * FROM bookmarks';
  const conditions = [];
  const params = {};

  if (filters.category) {
    conditions.push('category = @category');
    params.category = filters.category;
  }

  if (filters.search) {
    conditions.push('(title LIKE @search OR description LIKE @search)');
    params.search = `%${filters.search}%`;
  }

  if (conditions.length > 0) {
    sql += ' WHERE ' + conditions.join(' AND ');
  }

  sql += ' ORDER BY created_at DESC';

  const stmt = db.prepare(sql);
  return stmt.all(params);
}

// ID指定で取得
function findById(id) {
  const stmt = db.prepare('SELECT * FROM bookmarks WHERE id = ?');
  return stmt.get(id) || null;
}

// 新規作成
function create(data) {
  const stmt = db.prepare(`
    INSERT INTO bookmarks (title, url, category, description)
    VALUES (@title, @url, @category, @description)
  `);

  const result = stmt.run({
    title: data.title,
    url: data.url,
    category: data.category || '未分類',
    description: data.description || '',
  });

  return findById(result.lastInsertRowid);
}

// 更新
function update(id, data) {
  const existing = findById(id);
  if (!existing) return null;

  const stmt = db.prepare(`
    UPDATE bookmarks
    SET title = @title,
        url = @url,
        category = @category,
        description = @description,
        updated_at = datetime('now')
    WHERE id = @id
  `);

  stmt.run({
    id,
    title: data.title ?? existing.title,
    url: data.url ?? existing.url,
    category: data.category ?? existing.category,
    description: data.description ?? existing.description,
  });

  return findById(id);
}

// 削除
function remove(id) {
  const existing = findById(id);
  if (!existing) return null;

  const stmt = db.prepare('DELETE FROM bookmarks WHERE id = ?');
  stmt.run(id);
  return existing;
}

module.exports = { findAll, findById, create, update, remove };

Prepared Statementsとは

コードの中で db.prepare(sql) を使っています。これは Prepared Statement(プリペアドステートメント) と呼ばれる仕組みです。

// 危険:SQL文に直接値を埋め込む(SQLインジェクションの危険)
db.exec(`SELECT * FROM bookmarks WHERE category = '${userInput}'`);

// 安全:Prepared Statementを使う
const stmt = db.prepare('SELECT * FROM bookmarks WHERE category = ?');
stmt.all(userInput);

Prepared Statementは以下の利点があります。

  1. SQLインジェクション防止: ユーザー入力がSQL文として実行されない
  2. パフォーマンス向上: SQL文の解析結果がキャッシュされる
  3. 可読性: SQL文とパラメータが分離される

better-sqlite3 では、名前付きパラメータ(@name)と位置パラメータ(?)の両方が使えます。

// 名前付きパラメータ(オブジェクトで値を渡す)
const stmt = db.prepare('SELECT * FROM bookmarks WHERE category = @category');
stmt.all({ category: '技術' });

// 位置パラメータ(順番に値を渡す)
const stmt = db.prepare('SELECT * FROM bookmarks WHERE id = ?');
stmt.get(1);

初期データの投入

テスト用の初期データを投入するスクリプトを作っておくと便利です。

> src/db/seed.js に初期データ投入スクリプトを作って。
  5件のサンプルブックマークを登録して。
  node src/db/seed.js で実行できるようにして。
// src/db/seed.js
const db = require('./database');

// 既存データを削除
db.exec('DELETE FROM bookmarks');

// サンプルデータを投入
const insert = db.prepare(`
  INSERT INTO bookmarks (title, url, category, description)
  VALUES (@title, @url, @category, @description)
`);

const bookmarks = [
  { title: 'Express公式', url: 'https://expressjs.com', category: '技術', description: 'Express.js公式ドキュメント' },
  { title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: '技術', description: 'Web技術のリファレンス' },
  { title: 'GitHub', url: 'https://github.com', category: '開発ツール', description: 'コード管理プラットフォーム' },
  { title: 'Qiita', url: 'https://qiita.com', category: '技術', description: '技術情報共有サービス' },
  { title: 'Zenn', url: 'https://zenn.dev', category: '技術', description: 'エンジニア向け情報共有' },
];

// トランザクションで一括投入(高速)
const insertMany = db.transaction((items) => {
  for (const item of items) {
    insert.run(item);
  }
});

insertMany(bookmarks);
console.log(`${bookmarks.length}件のブックマークを投入しました`);
node src/db/seed.js
# → 5件のブックマークを投入しました

db.transaction() で囲むと、全ての INSERT が1回のトランザクションとして実行されます。データの一貫性が保たれるだけでなく、パフォーマンスも大幅に向上します(1000件の挿入でも数ミリ秒で完了)。

実践ワーク

  1. better-sqlite3 をインストールし、src/db/database.js でテーブルを作成する
  2. src/data/bookmarkStore.js をインメモリからDB版に書き換える
  3. src/db/seed.js で初期データを投入する
  4. サーバーを再起動しても、投入したデータが残っていることを確認する
  5. curl で全CRUDエンドポイントが正常に動作することを確認する
  6. (発展)カテゴリ用のテーブル categories を追加し、bookmarks.category を外部キーにする

まとめと次回の準備

今回のポイント: - SQLiteはファイルベースのDBで、セットアップ不要・1ファイルで完結 - better-sqlite3 はNode.jsからSQLiteを操作する高速なパッケージ - Prepared Statementを使えばSQLインジェクションを防止できる - db.transaction() でトランザクション処理ができる - データストアの差し替えだけで、ルートハンドラの変更なしにDB化できた

次回: バリデーションを強化します。Zodを使って、入力データの型チェック・フォーマット検証・サニタイズを体系的に実装します。

参考文献: - better-sqlite3(https://github.com/WiseLibs/better-sqlite3) - SQLite公式ドキュメント(https://www.sqlite.org/docs.html) - SQLite WALモード解説(https://www.sqlite.org/wal.html)

Lecture 6バリデーション — 入力データを検証する

12:00

バリデーション — 入力データを検証する

バリデーションの重要性

前回までのCRUD実装では、「タイトルが空でないか」「URLが有効か」を手動でチェックしていました。しかし現実のAPIでは、検証すべき項目はもっと多くなります。文字数の上限、許可されるカテゴリ値、メールアドレスの形式 -- これら全てを if 文で書くと、ルートハンドラが肥大化してコードの見通しが悪くなります。

バリデーションライブラリを使えば、検証ルールを宣言的に定義でき、コードの可読性と保守性が大幅に向上します。

バリデーションが不十分な場合のリスク:

リスク
データ破損 空文字のタイトル、不正なURLがDBに保存される
セキュリティ SQLインジェクション、XSSの入り口になる
バグの温床 想定外のデータ型が後続処理でエラーを引き起こす
UX低下 曖昧なエラーメッセージでユーザーが修正方法を理解できない

Zodの導入

本講座では Zod をバリデーションライブラリとして使います。ZodはTypeScript/JavaScript向けのスキーマ宣言・バリデーションライブラリで、以下の特徴があります。

  • スキーマ定義がシンプルで直感的
  • TypeScriptの型を自動生成できる(将来TypeScriptに移行する際に有利)
  • エラーメッセージのカスタマイズが容易
  • データの変換(trim、型変換)も同時に行える

Claude Codeに導入を依頼しましょう。

> ブックマーク管理APIにZodを導入して。
  npm install zodして、
  src/schemas/bookmark.js にブックマークの
  バリデーションスキーマを定義して。
  タイトル(必須、1-200文字)、
  URL(必須、有効なURL)、
  カテゴリ(任意、許可リスト)、
  説明(任意、500文字以内)。
npm install zod
// src/schemas/bookmark.js
const { z } = require('zod');

// 許可されるカテゴリのリスト
const ALLOWED_CATEGORIES = ['技術', '開発ツール', 'デザイン', 'ビジネス', 'エンタメ', '未分類'];

// ブックマーク作成用スキーマ
const createBookmarkSchema = z.object({
  title: z
    .string({ required_error: 'タイトルは必須です' })
    .trim()
    .min(1, 'タイトルは1文字以上で入力してください')
    .max(200, 'タイトルは200文字以内で入力してください'),

  url: z
    .string({ required_error: 'URLは必須です' })
    .trim()
    .url('有効なURLを入力してください'),

  category: z
    .string()
    .trim()
    .refine(val => ALLOWED_CATEGORIES.includes(val), {
      message: `カテゴリは次のいずれかを指定してください: ${ALLOWED_CATEGORIES.join(', ')}`,
    })
    .optional()
    .default('未分類'),

  description: z
    .string()
    .trim()
    .max(500, '説明は500文字以内で入力してください')
    .optional()
    .default(''),
});

// ブックマーク更新用スキーマ(全フィールドを任意に)
const updateBookmarkSchema = createBookmarkSchema.partial();

module.exports = {
  createBookmarkSchema,
  updateBookmarkSchema,
  ALLOWED_CATEGORIES,
};

スキーマの解説:

  • z.string() — 文字列型であることを検証
  • .trim() — 前後の空白を自動で削除(サニタイズ)
  • .min(1) — 最小文字数(trimした後)
  • .max(200) — 最大文字数
  • .url() — URL形式かどうかを検証
  • .refine() — カスタムバリデーション関数
  • .optional() — 省略可能(undefinedを許容)
  • .default('未分類') — 省略時のデフォルト値
  • .partial() — 全フィールドを optional に変換(更新時に便利)

バリデーションミドルウェアの作成

スキーマを直接ルートハンドラ内で使うこともできますが、ミドルウェアとして切り出すとどのルートでも再利用できます。

> src/middleware/validate.js に
  Zodスキーマを受け取ってバリデーションする
  ミドルウェアを作って。
  エラーがあれば { data: null, error: { message, details } } を返して。
// src/middleware/validate.js
const { ZodError } = require('zod');

/**
 * Zodスキーマでリクエストボディをバリデーションするミドルウェア
 * @param {import('zod').ZodSchema} schema - Zodスキーマ
 */
function validate(schema) {
  return (req, res, next) => {
    try {
      // parseで検証とデータ変換を同時に行う
      const validated = schema.parse(req.body);
      // バリデーション済みデータでreq.bodyを置き換え
      req.body = validated;
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        // Zodのエラーを整形
        const details = err.errors.map(e => ({
          field: e.path.join('.'),
          message: e.message,
        }));

        return res.status(400).json({
          data: null,
          error: {
            message: '入力データにエラーがあります',
            details,
          },
        });
      }
      next(err);
    }
  };
}

module.exports = validate;

このミドルウェアの仕組みを解説します。

  1. schema.parse(req.body) でリクエストボディを検証し、同時にtrim等のデータ変換も行います
  2. 検証に成功した場合、変換後のデータで req.body を上書きし、next() で次の処理に進みます
  3. 検証に失敗した場合、ZodError がスローされるので、エラーメッセージを整形してクライアントに返します

ルートへの適用

作成したバリデーションミドルウェアをルートに組み込みます。

// src/routes/bookmarks.js
const express = require('express');
const router = express.Router();
const store = require('../data/bookmarkStore');
const { success, error } = require('../utils/response');
const validate = require('../middleware/validate');
const { createBookmarkSchema, updateBookmarkSchema } = require('../schemas/bookmark');

// GET /api/bookmarks — 一覧取得(バリデーション不要)
router.get('/', (req, res) => {
  const filters = {
    category: req.query.category,
    search: req.query.search,
  };
  const bookmarks = store.findAll(filters);
  success(res, bookmarks);
});

// GET /api/bookmarks/:id — 個別取得
router.get('/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) return error(res, 'IDは数値で指定してください', 400);

  const bookmark = store.findById(id);
  if (!bookmark) return error(res, 'ブックマークが見つかりません', 404);
  success(res, bookmark);
});

// POST /api/bookmarks — 新規作成(バリデーションミドルウェア適用)
router.post('/', validate(createBookmarkSchema), (req, res) => {
  const bookmark = store.create(req.body);
  success(res, bookmark, 201);
});

// PUT /api/bookmarks/:id — 更新(バリデーションミドルウェア適用)
router.put('/:id', validate(updateBookmarkSchema), (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) return error(res, 'IDは数値で指定してください', 400);

  const bookmark = store.update(id, req.body);
  if (!bookmark) return error(res, 'ブックマークが見つかりません', 404);
  success(res, bookmark);
});

// DELETE /api/bookmarks/:id — 削除
router.delete('/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) return error(res, 'IDは数値で指定してください', 400);

  const deleted = store.remove(id);
  if (!deleted) return error(res, 'ブックマークが見つかりません', 404);
  success(res, deleted);
});

module.exports = router;

validate(createBookmarkSchema) をルートの第2引数に置くだけで、バリデーションが自動で実行されます。検証を通過したデータだけがルートハンドラに届くため、ハンドラ内でのバリデーションコードは不要になります。

サニタイズ(データの正規化)

バリデーションと合わせて重要なのが サニタイズ です。Zodの .trim().default() がサニタイズの役割を果たします。

// 入力データ
{
  title: "  Express.js  ",   // 前後に余計な空白
  url: "https://expressjs.com",
  // category は省略
  // description は省略
}

// Zodスキーマ通過後
{
  title: "Express.js",       // trimで空白除去
  url: "https://expressjs.com",
  category: "未分類",         // default値が適用
  description: ""             // default値が適用
}

サニタイズにより、データベースに保存されるデータが常に綺麗な状態になります。

curlでバリデーションをテストする

# 正常な作成
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title":"Node.js","url":"https://nodejs.org","category":"技術"}'

# タイトルなし → エラー
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

# 不正なURL → エラー
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title":"テスト","url":"not-a-url"}'

# 許可されていないカテゴリ → エラー
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title":"テスト","url":"https://example.com","category":"無効"}'

# タイトルが201文字 → エラー
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d "{\"title\":\"$(python3 -c 'print("a" * 201)')\",\"url\":\"https://example.com\"}"

実践ワーク

  1. zod をインストールし、src/schemas/bookmark.js にスキーマを定義する
  2. src/middleware/validate.js にバリデーションミドルウェアを作成する
  3. POSTPUT のルートにバリデーションミドルウェアを適用する
  4. 各種不正データ(空文字、不正URL、長すぎるタイトル、不正なカテゴリ)でエラーが返ることを確認する
  5. trim() によるサニタイズが動作していることを確認する(前後に空白を含むタイトルを送信)
  6. (発展)クエリパラメータ用のバリデーションスキーマを作成し、GET /api/bookmarks?category=xxx の category も検証する

まとめと次回の準備

今回のポイント: - バリデーションはセキュリティ・データ品質・UXの全てに関わる重要な機能 - Zodでスキーマを宣言的に定義し、検証とサニタイズを一括で行える - ミドルウェアとして切り出すことで、全ルートで再利用できる - .partial() で更新用スキーマを簡単に作成できる

次回: JWT認証を実装します。ユーザー登録・ログイン機能を追加し、ブックマークを認証済みユーザーだけが操作できるようにします。

参考文献: - Zod公式ドキュメント(https://zod.dev/) - OWASP Input Validation Cheat Sheet(https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html) - Express.js「エラー処理」(https://expressjs.com/ja/guide/error-handling.html)

Lecture 7認証機能 — JWTでユーザー認証する

12:00

認証機能 — JWTでユーザー認証する

認証と認可の違い

APIを公開する際、「誰がアクセスしているか」を識別し、「何をしてよいか」を制御する仕組みが必要です。これが 認証(Authentication)認可(Authorization) です。

概念 意味
認証 「あなたは誰ですか?」を確認する ログイン(メール + パスワード)
認可 「あなたは何をしてよいですか?」を決める 管理者は全操作可、一般ユーザーは閲覧のみ

本講座では JWT(JSON Web Token) を使った認証を実装します。JWTはステートレスなトークンベース認証で、REST APIと相性が良い方式です。

JWTの仕組み

JWT認証の流れは以下のとおりです。

1. ユーザーがメール+パスワードでログイン
2. サーバーがパスワードを検証しJWTトークンを生成して返す
3. クライアントはトークンを保存し以降のリクエストの
   Authorization ヘッダーに付与する
4. サーバーはトークンを検証し有効なら処理を続行する

JWTトークンは3つの部分をドット(.)で結合した文字列です。

xxxxx.yyyyy.zzzzz
ヘッダー.ペイロード.署名
  • ヘッダー: トークンの種類と署名アルゴリズム
  • ペイロード: ユーザーID等の情報(クレーム)
  • 署名: 改ざん検知用のハッシュ値(秘密鍵で生成)

署名があるため、クライアント側でペイロードを書き換えても、サーバー側で検証に失敗します。

パッケージのインストールとusersテーブル

Claude Codeに認証機能の基盤を作ってもらいましょう。

> ブックマーク管理APIにJWT認証を追加して。
  jsonwebtokenとbcryptjsをインストールして。
  usersテーブルを作成して(id, email, password_hash, name)。
  src/routes/auth.js に登録・ログインの
  エンドポイントを作って。
npm install jsonwebtoken bcryptjs

まず、usersテーブルを追加します。

// src/db/database.js に追加
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    name TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

// bookmarksテーブルにuser_idカラムを追加
// (既存テーブルがある場合)
try {
  db.exec('ALTER TABLE bookmarks ADD COLUMN user_id INTEGER REFERENCES users(id)');
} catch {
  // カラムが既に存在する場合はエラーを無視
}

ユーザー登録エンドポイント

// src/routes/auth.js
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../db/database');
const { z } = require('zod');

const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const JWT_EXPIRES_IN = '24h';

// 登録用スキーマ
const registerSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
  name: z.string().trim().min(1, '名前は必須です').max(100),
});

// POST /api/auth/register — ユーザー登録
router.post('/register', async (req, res) => {
  try {
    const { email, password, name } = registerSchema.parse(req.body);

    // メールアドレスの重複チェック
    const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
    if (existing) {
      return res.status(409).json({
        data: null,
        error: 'このメールアドレスは既に登録されています',
      });
    }

    // パスワードのハッシュ化
    const salt = await bcrypt.genSalt(10);
    const passwordHash = await bcrypt.hash(password, salt);

    // ユーザー作成
    const stmt = db.prepare(
      'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)'
    );
    const result = stmt.run(email, passwordHash, name);

    // JWTトークン生成
    const token = jwt.sign(
      { userId: result.lastInsertRowid, email },
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );

    res.status(201).json({
      data: {
        user: { id: result.lastInsertRowid, email, name },
        token,
      },
      error: null,
    });
  } catch (err) {
    if (err.name === 'ZodError') {
      return res.status(400).json({
        data: null,
        error: { message: '入力データにエラーがあります', details: err.errors },
      });
    }
    res.status(500).json({ data: null, error: 'サーバーエラーが発生しました' });
  }
});

module.exports = router;

パスワードのハッシュ化 が重要なポイントです。パスワードは 絶対にそのまま保存してはいけませんbcrypt.hash() で一方向ハッシュに変換し、ハッシュ値だけをデータベースに保存します。ハッシュ値からパスワードを復元することは計算上不可能です。

bcrypt.genSalt(10)10 はソルトラウンド数です。数値が大きいほど安全ですが処理時間が増えます。10は一般的な推奨値です。

ログインエンドポイント

// src/routes/auth.js に追加

// ログイン用スキーマ
const loginSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(1, 'パスワードは必須です'),
});

// POST /api/auth/login — ログイン
router.post('/login', async (req, res) => {
  try {
    const { email, password } = loginSchema.parse(req.body);

    // ユーザー検索
    const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
    if (!user) {
      return res.status(401).json({
        data: null,
        error: 'メールアドレスまたはパスワードが正しくありません',
      });
    }

    // パスワード検証
    const isValid = await bcrypt.compare(password, user.password_hash);
    if (!isValid) {
      return res.status(401).json({
        data: null,
        error: 'メールアドレスまたはパスワードが正しくありません',
      });
    }

    // JWTトークン生成
    const token = jwt.sign(
      { userId: user.id, email: user.email },
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );

    res.json({
      data: {
        user: { id: user.id, email: user.email, name: user.name },
        token,
      },
      error: null,
    });
  } catch (err) {
    if (err.name === 'ZodError') {
      return res.status(400).json({
        data: null,
        error: { message: '入力データにエラーがあります', details: err.errors },
      });
    }
    res.status(500).json({ data: null, error: 'サーバーエラーが発生しました' });
  }
});

セキュリティ上の注意: ログイン失敗時のエラーメッセージは「メールアドレスまたはパスワードが正しくありません」と曖昧にします。「メールアドレスが存在しません」と「パスワードが違います」を区別してしまうと、攻撃者に「このメールアドレスは登録済みだ」という情報を与えてしまいます。

認証ミドルウェア

JWTトークンを検証するミドルウェアを作成します。

> src/middleware/auth.js にJWT認証ミドルウェアを作って。
  Authorization: Bearer <token> ヘッダーからトークンを取得して、
  検証が成功したら req.user にユーザー情報をセットして。
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';

function authenticate(req, res, next) {
  // Authorizationヘッダーからトークンを取得
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      data: null,
      error: '認証トークンが必要です',
    });
  }

  const token = authHeader.split(' ')[1];

  try {
    // トークンの検証
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = { userId: decoded.userId, email: decoded.email };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({
        data: null,
        error: 'トークンの有効期限が切れています',
      });
    }
    return res.status(401).json({
      data: null,
      error: '無効なトークンです',
    });
  }
}

module.exports = authenticate;

保護されたルートの設定

認証ミドルウェアをブックマークルートに適用します。

// src/index.js
const authRoutes = require('./routes/auth');
const bookmarkRoutes = require('./routes/bookmarks');
const authenticate = require('./middleware/auth');

// 認証不要
app.use('/api/auth', authRoutes);

// 認証必要(authenticateミドルウェアを挟む)
app.use('/api/bookmarks', authenticate, bookmarkRoutes);

これにより、/api/bookmarks 以下の全エンドポイントにアクセスするには、有効なJWTトークンが必要になります。ルートハンドラ内では req.user.userId でログインユーザーのIDを取得できます。

curlで認証フローをテストする

# 1. ユーザー登録
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","name":"テストユーザー"}'

# レスポンスのtokenをコピー

# 2. トークンなしでアクセス → 401エラー
curl http://localhost:3000/api/bookmarks

# 3. トークン付きでアクセス → 成功
curl http://localhost:3000/api/bookmarks \
  -H "Authorization: Bearer ここにトークンを貼る"

# 4. ログイン
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'

実践ワーク

  1. jsonwebtokenbcryptjs をインストールする
  2. users テーブルを作成する
  3. src/routes/auth.js に登録・ログインエンドポイントを実装する
  4. src/middleware/auth.js にJWT認証ミドルウェアを作成する
  5. ブックマークルートに認証ミドルウェアを適用する
  6. 登録 → ログイン → トークン付きリクエストの一連のフローを curl でテストする
  7. (発展)ブックマークに user_id を紐付け、自分のブックマークだけを取得・操作できるようにする

まとめと次回の準備

今回のポイント: - 認証はユーザーの特定、認可は権限の制御 - JWTはステートレスなトークンベース認証でREST APIと好相性 - パスワードは bcrypt でハッシュ化して保存(絶対に平文で保存しない) - 認証ミドルウェアで req.user にユーザー情報をセットする - Authorization: Bearer <token> ヘッダーでトークンを送信する

次回: エラーハンドリングを体系的に実装します。カスタムエラークラス、非同期エラーのキャッチ、404ハンドラなど、APIを堅牢にする仕組みを整えます。

参考文献: - JWT.io — JWTの仕組みとデバッガー(https://jwt.io/) - jsonwebtoken パッケージ(https://www.npmjs.com/package/jsonwebtoken) - OWASP Authentication Cheat Sheet(https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)

Lecture 8エラーハンドリング — 堅牢なAPIにする

12:00

エラーハンドリング — 堅牢なAPIにする

なぜエラーハンドリングが重要か

これまでの講義で、ブックマーク管理APIの基本機能は完成しています。しかし、現実のAPIでは予想外のエラーが必ず発生します。データベース接続の障害、不正なリクエスト、存在しないエンドポイントへのアクセス、処理中の予期しない例外 -- これらを適切に処理しないと、サーバーがクラッシュしたり、クライアントに500エラーの生のスタックトレースが漏れたりします。

エラーハンドリングが不十分なAPIの問題:

問題 影響
キャッチされない例外でサーバーがクラッシュ 全ユーザーがサービスを利用できなくなる
スタックトレースがレスポンスに含まれる 内部実装の情報が攻撃者に漏れる
エラーメッセージが不統一 クライアントがエラー処理を書きにくい
ログが残らない 問題の原因調査ができない

本講義では、これら全ての問題に対処するエラーハンドリングの仕組みを構築します。

カスタムエラークラス

まず、アプリケーション固有のエラークラスを定義します。Claude Codeに設計を依頼しましょう。

> src/utils/errors.js にカスタムエラークラスを作って。
  AppError をベースに、
  NotFoundError(404)、
  ValidationError(400)、
  AuthenticationError(401)、
  ForbiddenError(403)を定義して。
  それぞれステータスコードを持つようにして。
// src/utils/errors.js

class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = true; // 運用上のエラー(予期されるエラー)

    // スタックトレースからこのコンストラクタを除外
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(message = 'リソースが見つかりません') {
    super(message, 404);
  }
}

class ValidationError extends AppError {
  constructor(message = '入力データが不正です', details = []) {
    super(message, 400);
    this.details = details;
  }
}

class AuthenticationError extends AppError {
  constructor(message = '認証が必要です') {
    super(message, 401);
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'この操作は許可されていません') {
    super(message, 403);
  }
}

module.exports = {
  AppError,
  NotFoundError,
  ValidationError,
  AuthenticationError,
  ForbiddenError,
};

isOperational プロパティは「このエラーは想定内か」を示すフラグです。存在しないリソースへのアクセス(想定内)とプログラムのバグによるTypeError(想定外)を区別するために使います。想定外のエラーはより深刻で、ログに詳細を記録する必要があります。

エラーハンドリングミドルウェア

Expressでは、引数が4つ のミドルウェアがエラーハンドラとして認識されます。

> src/middleware/errorHandler.js に
  グローバルエラーハンドリングミドルウェアを作って。
  AppError系は適切なステータスコードで、
  予期しないエラーは500で返す。
  本番環境ではスタックトレースを隠す。
// src/middleware/errorHandler.js
const { AppError } = require('../utils/errors');

function errorHandler(err, req, res, next) {
  // デフォルト値
  let statusCode = err.statusCode || 500;
  let message = err.message || 'サーバー内部エラーが発生しました';
  let details = err.details || undefined;

  // 予期しないエラーのログ出力
  if (!err.isOperational) {
    console.error('=== 予期しないエラー ===');
    console.error(`${req.method} ${req.originalUrl}`);
    console.error(err.stack);
    console.error('========================');

    // 本番環境では詳細を隠す
    if (process.env.NODE_ENV === 'production') {
      statusCode = 500;
      message = 'サーバー内部エラーが発生しました';
      details = undefined;
    }
  }

  // 統一レスポンス形式
  const response = {
    data: null,
    error: {
      message,
      ...(details && { details }),
      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
    },
  };

  res.status(statusCode).json(response);
}

module.exports = errorHandler;

重要な設計判断:

  1. 本番環境ではスタックトレースを隠す: process.env.NODE_ENV === 'production' の場合、エラーの内部詳細をクライアントに返しません。スタックトレースにはファイルパスや行番号が含まれ、攻撃者に有用な情報を与えてしまいます
  2. 予期しないエラーは必ずログに残す: isOperationalfalse のエラーはバグの可能性が高いため、サーバーのコンソールに詳細を出力します
  3. レスポンス形式は常に統一: 成功時と同じ { data, error } 形式を維持します

404ハンドラ

存在しないエンドポイントにアクセスがあった場合のハンドラを追加します。

// src/middleware/notFound.js
const { NotFoundError } = require('../utils/errors');

function notFound(req, res, next) {
  next(new NotFoundError(`${req.method} ${req.originalUrl} は存在しません`));
}

module.exports = notFound;

非同期エラーのキャッチ

Express 4.x では、非同期関数(async/await)内で発生したエラーは自動でキャッチされません。明示的に try/catch で囲むか、ラッパー関数を使う必要があります。

> src/utils/asyncHandler.js に
  async関数のエラーを自動キャッチする
  ラッパー関数を作って。
// src/utils/asyncHandler.js

/**
 * async関数をラップしてエラーを自動キャッチする
 * try/catchを毎回書く必要がなくなる
 */
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

module.exports = asyncHandler;

使い方は簡単です。ルートハンドラを asyncHandler() で囲むだけです。

const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError } = require('../utils/errors');

// asyncHandler なし → try/catchが必要
router.get('/:id', async (req, res, next) => {
  try {
    const bookmark = await findBookmark(req.params.id);
    if (!bookmark) throw new NotFoundError();
    res.json({ data: bookmark, error: null });
  } catch (err) {
    next(err);
  }
});

// asyncHandler あり → try/catch不要、エラーは自動でnextに渡される
router.get('/:id', asyncHandler(async (req, res) => {
  const bookmark = await findBookmark(req.params.id);
  if (!bookmark) throw new NotFoundError();
  res.json({ data: bookmark, error: null });
}));

Express 5.x(現在ベータ版)では非同期エラーが自動キャッチされるようになりますが、Express 4.x を使う現時点では asyncHandler が必須です。

ルートハンドラでのエラー使用例

カスタムエラークラスと asyncHandler を使って、ルートハンドラをリファクタリングします。

// src/routes/bookmarks.js(エラーハンドリング改善版)
const express = require('express');
const router = express.Router();
const store = require('../data/bookmarkStore');
const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError, ValidationError } = require('../utils/errors');
const validate = require('../middleware/validate');
const { createBookmarkSchema, updateBookmarkSchema } = require('../schemas/bookmark');

router.get('/:id', asyncHandler(async (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) throw new ValidationError('IDは数値で指定してください');

  const bookmark = store.findById(id);
  if (!bookmark) throw new NotFoundError('ブックマークが見つかりません');

  res.json({ data: bookmark, error: null });
}));

router.post('/', validate(createBookmarkSchema), asyncHandler(async (req, res) => {
  const bookmark = store.create(req.body);
  res.status(201).json({ data: bookmark, error: null });
}));

// 他のルートも同様にリファクタリング

throw new NotFoundError() するだけで、エラーは asyncHandlernext()errorHandler の流れで適切に処理されます。

ミドルウェアの適用順序

エラー関連のミドルウェアは ルート定義の後に 配置する必要があります。

// src/index.js
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const bookmarkRoutes = require('./routes/bookmarks');
const authRoutes = require('./routes/auth');
const authenticate = require('./middleware/auth');
const notFound = require('./middleware/notFound');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// 1. 基本ミドルウェア
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());

// 2. ルート定義
app.get('/', (req, res) => {
  res.json({ message: 'Bookmark API is running' });
});
app.use('/api/auth', authRoutes);
app.use('/api/bookmarks', authenticate, bookmarkRoutes);

// 3. 404ハンドラ(ルートの後、エラーハンドラの前)
app.use(notFound);

// 4. エラーハンドラ(必ず最後)
app.use(errorHandler);

app.listen(process.env.PORT || 3000);

この順序が重要です。リクエストは上から下へミドルウェアを通過し、どのルートにもマッチしなかった場合に notFound に到達します。エラーハンドラは必ず最後に配置し、上流で発生した全てのエラーをキャッチします。

実践ワーク

  1. src/utils/errors.js にカスタムエラークラスを作成する
  2. src/middleware/errorHandler.js にグローバルエラーハンドラを作成する
  3. src/middleware/notFound.js に404ハンドラを作成する
  4. src/utils/asyncHandler.js に非同期ラッパーを作成する
  5. ルートハンドラをカスタムエラークラスと asyncHandler を使ってリファクタリングする
  6. 存在しないURL(例: /api/nothing)にアクセスして404レスポンスを確認する
  7. NODE_ENV=production で起動し、エラーレスポンスからスタックトレースが消えることを確認する

まとめと次回の準備

今回のポイント: - カスタムエラークラスでエラーの種類ごとにステータスコードを管理 - 4引数のミドルウェアがExpressのエラーハンドラとして機能する - asyncHandler で非同期エラーを自動キャッチ - 本番環境ではスタックトレースを隠してセキュリティを確保 - ミドルウェアの配置順序(ルート → 404 → エラーハンドラ)が重要

次回: テストを書きます。vitestとsupertestを使い、各エンドポイントが期待通りに動作することを自動テストで保証します。

参考文献: - Express.js「エラー処理」(https://expressjs.com/ja/guide/error-handling.html) - Node.js「Error クラス」(https://nodejs.org/api/errors.html) - OWASP Error Handling Cheat Sheet(https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)

Lecture 9テスト — APIをテストする

12:00

テスト — APIをテストする

なぜテストを書くのか

ここまでの講義では、APIの動作確認を curl コマンドで手動で行ってきました。しかし手動テストには限界があります。コードを変更するたびに全エンドポイントを手動で確認するのは時間がかかり、テスト漏れが発生しやすくなります。

自動テスト を書けば、コマンド1つで全てのエンドポイントの動作を検証できます。コードを変更した後にテストを実行すれば、既存の機能が壊れていないこと(リグレッションがないこと)を即座に確認できます。

テストの種類 対象 実行速度
ユニットテスト 個別の関数やモジュール 最速(ミリ秒)
統合テスト APIエンドポイント全体 速い(秒)
E2Eテスト ブラウザを含むシステム全体 遅い(分)

本講座では 統合テスト(Integration Test) に焦点を当てます。HTTPリクエストを送信し、レスポンスのステータスコード・ボディ・ヘッダーが期待通りかを検証します。

テストツールのセットアップ

テストフレームワークには Vitest(高速でESM対応)、HTTPテストには Supertest を使います。Claude Codeにセットアップを依頼しましょう。

> ブックマーク管理APIにテスト環境を構築して
  vitest  supertest をインストールして
  package.json にテスト用スクリプトを追加して
  テスト用のDB設定も分離して
npm install --save-dev vitest supertest
// package.json のscriptsに追加
{
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

アプリケーションのエクスポート

テストからExpressアプリを使うために、app をエクスポートする必要があります。サーバーの起動(app.listen)とアプリケーション定義を分離します。

// src/app.js(新規作成 — アプリケーション定義)
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const bookmarkRoutes = require('./routes/bookmarks');
const authRoutes = require('./routes/auth');
const authenticate = require('./middleware/auth');
const notFound = require('./middleware/notFound');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.use(cors());
if (process.env.NODE_ENV !== 'test') {
  app.use(morgan('dev')); // テスト中はログを抑制
}
app.use(express.json());

app.get('/', (req, res) => {
  res.json({ message: 'Bookmark API is running' });
});
app.use('/api/auth', authRoutes);
app.use('/api/bookmarks', authenticate, bookmarkRoutes);

app.use(notFound);
app.use(errorHandler);

module.exports = app;
// src/index.js(サーバー起動のみ)
const app = require('./app');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

この分離により、テストでは app をインポートして Supertest に渡すだけで、実際にサーバーを起動せずにテストできます。

テスト用データベースの設定

テストは本番データベースとは別の、テスト専用データベースを使うべきです。

> テスト用のデータベース設定を作って。
  テスト実行時はインメモリSQLiteを使うようにして。
  各テストの前にテーブルをリセットする
  ヘルパー関数も作って。
// src/db/database.js(環境対応版)
const Database = require('better-sqlite3');
const path = require('path');

const isTest = process.env.NODE_ENV === 'test';

// テスト時はインメモリDB、通常時はファイルDB
const DB_PATH = isTest ? ':memory:' : path.join(__dirname, '../../data/bookmarks.sqlite');
const db = new Database(DB_PATH);

db.pragma('journal_mode = WAL');

// テーブル作成
function initTables() {
  db.exec(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT NOT NULL UNIQUE,
      password_hash TEXT NOT NULL,
      name TEXT NOT NULL,
      created_at TEXT DEFAULT (datetime('now'))
    )
  `);

  db.exec(`
    CREATE TABLE IF NOT EXISTS bookmarks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      url TEXT NOT NULL,
      category TEXT DEFAULT '未分類',
      description TEXT DEFAULT '',
      user_id INTEGER REFERENCES users(id),
      created_at TEXT DEFAULT (datetime('now')),
      updated_at TEXT DEFAULT (datetime('now'))
    )
  `);
}

// テーブルのデータをリセット(テスト用)
function resetTables() {
  db.exec('DELETE FROM bookmarks');
  db.exec('DELETE FROM users');
  db.exec("DELETE FROM sqlite_sequence WHERE name IN ('bookmarks', 'users')");
}

initTables();

module.exports = db;
module.exports.resetTables = resetTables;

最初のテストを書く

ルートエンドポイント(GET /)のテストから始めましょう。

> tests/api/root.test.js に
  ルートエンドポイントのテストを書いて。
  Supertestでリクエストして、
  ステータスコードとレスポンスボディを検証して。
// tests/api/root.test.js
const { describe, it, expect } = require('vitest');
const request = require('supertest');
const app = require('../../src/app');

describe('GET /', () => {
  it('APIの稼働メッセージを返す', async () => {
    const res = await request(app).get('/');

    expect(res.status).toBe(200);
    expect(res.body).toEqual({
      message: 'Bookmark API is running',
    });
  });

  it('Content-TypeがJSONである', async () => {
    const res = await request(app).get('/');

    expect(res.headers['content-type']).toMatch(/json/);
  });
});
npm test

request(app) でSupertestにExpressアプリを渡します。実際のHTTPサーバーを起動せずに、内部的にリクエストを処理してレスポンスを返します。

ブックマークCRUDのテスト

認証が必要なエンドポイントのテストでは、テストの前にユーザー登録してトークンを取得する必要があります。

> tests/api/bookmarks.test.js に
  ブックマークのCRUDテストを書いて。
  beforeEachでDB初期化とユーザー登録を行い、
  全5エンドポイント(GET一覧、GET個別、POST、PUT、DELETE)
  をテストして。正常系とエラー系の両方を書いて。
// tests/api/bookmarks.test.js
const { describe, it, expect, beforeEach } = require('vitest');
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db/database');

describe('Bookmarks API', () => {
  let token;

  beforeEach(async () => {
    // DBリセット
    db.resetTables();

    // テストユーザー登録してトークン取得
    const res = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'password123',
        name: 'テストユーザー',
      });

    token = res.body.data.token;
  });

  describe('POST /api/bookmarks', () => {
    it('新しいブックマークを作成できる', async () => {
      const res = await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({
          title: 'Express公式',
          url: 'https://expressjs.com',
          category: '技術',
        });

      expect(res.status).toBe(201);
      expect(res.body.data).toMatchObject({
        title: 'Express公式',
        url: 'https://expressjs.com',
        category: '技術',
      });
      expect(res.body.data.id).toBeDefined();
      expect(res.body.error).toBeNull();
    });

    it('タイトルなしで400エラーを返す', async () => {
      const res = await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({ url: 'https://example.com' });

      expect(res.status).toBe(400);
      expect(res.body.error).toBeDefined();
    });

    it('トークンなしで401エラーを返す', async () => {
      const res = await request(app)
        .post('/api/bookmarks')
        .send({ title: 'テスト', url: 'https://example.com' });

      expect(res.status).toBe(401);
    });
  });

  describe('GET /api/bookmarks', () => {
    beforeEach(async () => {
      // テストデータ投入
      await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({ title: 'サイト1', url: 'https://site1.com', category: '技術' });
      await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({ title: 'サイト2', url: 'https://site2.com', category: 'デザイン' });
    });

    it('ブックマーク一覧を取得できる', async () => {
      const res = await request(app)
        .get('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(200);
      expect(res.body.data).toHaveLength(2);
    });

    it('カテゴリで絞り込みできる', async () => {
      const res = await request(app)
        .get('/api/bookmarks?category=技術')
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(200);
      expect(res.body.data).toHaveLength(1);
      expect(res.body.data[0].category).toBe('技術');
    });
  });

  describe('GET /api/bookmarks/:id', () => {
    it('IDで個別取得できる', async () => {
      // まず作成
      const created = await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({ title: 'テスト', url: 'https://test.com' });

      const id = created.body.data.id;

      const res = await request(app)
        .get(`/api/bookmarks/${id}`)
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(200);
      expect(res.body.data.title).toBe('テスト');
    });

    it('存在しないIDで404を返す', async () => {
      const res = await request(app)
        .get('/api/bookmarks/9999')
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(404);
    });
  });

  describe('PUT /api/bookmarks/:id', () => {
    it('ブックマークを更新できる', async () => {
      const created = await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({ title: '更新前', url: 'https://before.com' });

      const id = created.body.data.id;

      const res = await request(app)
        .put(`/api/bookmarks/${id}`)
        .set('Authorization', `Bearer ${token}`)
        .send({ title: '更新後' });

      expect(res.status).toBe(200);
      expect(res.body.data.title).toBe('更新後');
    });
  });

  describe('DELETE /api/bookmarks/:id', () => {
    it('ブックマークを削除できる', async () => {
      const created = await request(app)
        .post('/api/bookmarks')
        .set('Authorization', `Bearer ${token}`)
        .send({ title: '削除対象', url: 'https://delete.com' });

      const id = created.body.data.id;

      const res = await request(app)
        .delete(`/api/bookmarks/${id}`)
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(200);

      // 再取得で404を確認
      const check = await request(app)
        .get(`/api/bookmarks/${id}`)
        .set('Authorization', `Bearer ${token}`);

      expect(check.status).toBe(404);
    });
  });
});

テストの構造と命名

テストコードの構造にはパターンがあります。

AAA パターン(Arrange-Act-Assert):

it('新しいブックマークを作成できる', async () => {
  // Arrange(準備)
  const bookmarkData = { title: 'テスト', url: 'https://test.com' };

  // Act(実行)
  const res = await request(app)
    .post('/api/bookmarks')
    .set('Authorization', `Bearer ${token}`)
    .send(bookmarkData);

  // Assert(検証)
  expect(res.status).toBe(201);
  expect(res.body.data.title).toBe('テスト');
});

テスト名のルール: 「何が」「どうなる」を日本語で明確に記述します。

// 良い例
it('タイトルなしで400エラーを返す');
it('存在しないIDで404を返す');
it('トークンなしで401エラーを返す');

// 悪い例
it('テスト1');
it('エラーハンドリング');

テストカバレッジの確認

テストがコードのどの部分を網羅しているかを確認できます。

// vitest.config.js
const { defineConfig } = require('vitest/config');

module.exports = defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});
npx vitest run --coverage

カバレッジレポートにはステートメント、ブランチ、関数、行ごとの網羅率が表示されます。100%を目指す必要はありませんが、主要なパス(正常系とエラー系)は網羅すべきです。

実践ワーク

  1. vitestsupertest をインストールし、テストスクリプトを追加する
  2. src/app.js にアプリケーション定義を分離する
  3. テスト用のインメモリDB設定を追加する
  4. tests/api/root.test.js にルートエンドポイントのテストを書く
  5. tests/api/bookmarks.test.js に全CRUDエンドポイントのテスト(正常系・エラー系)を書く
  6. npm test で全テストが通ることを確認する
  7. (発展)認証エンドポイント(登録・ログイン)のテストを追加する

まとめと次回の準備

今回のポイント: - 自動テストでリグレッション(退行)を防止できる - Supertest を使えばサーバー起動なしにHTTPリクエストをテストできる - applisten を分離してテスタビリティを確保 - テスト用DBはインメモリ + beforeEach でリセット - AAA パターン(Arrange-Act-Assert)で構造的にテストを書く

次回: APIサーバーをデプロイして公開します。環境変数の管理、本番設定、Railway/Renderへのデプロイ、Swaggerドキュメントの生成を行います。

参考文献: - Vitest公式ドキュメント(https://vitest.dev/) - Supertest パッケージ(https://www.npmjs.com/package/supertest) - Martin Fowler「テストピラミッド」(https://martinfowler.com/bliki/TestPyramid.html)

Lecture 10デプロイ — APIサーバーを公開する

12:00

デプロイ — APIサーバーを公開する

開発環境と本番環境の違い

これまでの講義では全て localhost:3000 で開発してきました。しかしAPIを実際に使ってもらうには、インターネット上のサーバーにデプロイ(配置・公開)する必要があります。

開発環境と本番環境の違いを整理しましょう。

項目 開発環境 本番環境
URL http://localhost:3000 https://my-api.example.com
データベース ローカルファイル 永続的なストレージ
秘密情報 ハードコード可 環境変数で管理
エラー表示 スタックトレースあり メッセージのみ
ログ コンソール出力 ログサービスへ送信
HTTPS 不要 必須

本番環境に移行する際に最も重要なのは 環境変数の管理セキュリティ設定 です。

環境変数の管理

秘密情報(JWTシークレット、データベースの接続文字列など)をコードにハードコードするのは絶対に避けなければなりません。dotenv パッケージを使って環境変数を管理します。

> ブックマーク管理APIに環境変数管理を導入して。
  dotenv をインストールして、
  .env ファイルと .env.example を作って。
  JWT_SECRET, PORT, NODE_ENV, DATABASE_URL の
  4つの環境変数を定義して。
npm install dotenv
# .env(このファイルはgitにコミットしない!)
PORT=3000
NODE_ENV=development
JWT_SECRET=your-super-secret-key-change-this-in-production
DATABASE_URL=./data/bookmarks.sqlite
# .env.example(gitにコミットする。値は空またはサンプル)
PORT=3000
NODE_ENV=development
JWT_SECRET=
DATABASE_URL=./data/bookmarks.sqlite
# .gitignore に追加
.env
data/*.sqlite
node_modules/

アプリケーションの起動時に dotenv を読み込みます。

// src/index.js の先頭
require('dotenv').config();

const app = require('./app');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT} (${process.env.NODE_ENV || 'development'})`);
});

環境変数を一元管理する設定ファイルを作ると、参照先が明確になります。

// src/config.js
require('dotenv').config();

module.exports = {
  port: parseInt(process.env.PORT || '3000'),
  nodeEnv: process.env.NODE_ENV || 'development',
  jwtSecret: process.env.JWT_SECRET || 'dev-secret',
  databaseUrl: process.env.DATABASE_URL || './data/bookmarks.sqlite',

  isProduction: process.env.NODE_ENV === 'production',
  isDevelopment: process.env.NODE_ENV === 'development',
  isTest: process.env.NODE_ENV === 'test',
};

本番用のセキュリティ設定

本番環境では追加のセキュリティ対策が必要です。Claude Codeに設定を依頼しましょう。

> ブックマーク管理APIに本番用のセキュリティ設定を追加して。
  helmetでセキュリティヘッダーを設定、
  express-rate-limitでレート制限、
  CORSを特定オリジンに限定して。
npm install helmet express-rate-limit
// src/app.js(本番セキュリティ追加版)
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const config = require('./config');

const app = express();

// セキュリティヘッダー
app.use(helmet());

// レート制限(1IPあたり15分間に100リクエストまで)
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: {
    data: null,
    error: 'リクエスト数の上限に達しました。しばらくしてから再試行してください。',
  },
});
app.use('/api/', limiter);

// CORS設定
app.use(cors({
  origin: config.isProduction
    ? 'https://your-frontend.com'  // 本番は特定オリジンのみ
    : '*',                          // 開発は全て許可
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// ログ
if (!config.isTest) {
  app.use(morgan(config.isProduction ? 'combined' : 'dev'));
}

app.use(express.json({ limit: '1mb' })); // ボディサイズ制限

// ... ルート定義

各セキュリティ対策の解説:

  • helmet: X-Content-Type-OptionsX-Frame-OptionsStrict-Transport-Security などのセキュリティ関連ヘッダーを自動で設定します
  • express-rate-limit: 同一IPからの過剰なリクエスト(DoS攻撃やブルートフォース攻撃)を防止します
  • CORS制限: 本番では信頼できるフロントエンドのオリジンだけを許可します
  • ボディサイズ制限: express.json({ limit: '1mb' }) で巨大なリクエストボディを拒否します

Railwayへのデプロイ

Railway は、GitHubリポジトリから自動でデプロイできるPaaS(Platform as a Service)です。無料プランで小規模なAPIを運用できます。

手順

> ブックマーク管理APIをRailwayにデプロイするための
  設定ファイルを作って。
  Procfile と railway.json を作成して。

1. GitHubリポジトリの準備

git init
git add .
git commit -m "Initial commit: Bookmark API"
git remote add origin https://github.com/your-username/bookmark-api.git
git push -u origin main

2. Railwayの設定

// railway.json
{
  "$schema": "https://railway.app/railway.schema.json",
  "build": {
    "builder": "NIXPACKS"
  },
  "deploy": {
    "startCommand": "node src/index.js",
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 3
  }
}

3. Railwayでのデプロイ手順

  1. https://railway.app にアクセスしてGitHubアカウントでログイン
  2. 「New Project」→「Deploy from GitHub repo」を選択
  3. リポジトリを選択
  4. 「Variables」タブで環境変数を設定:
  5. NODE_ENV = production
  6. JWT_SECRET = 強力なランダム文字列(openssl rand -hex 32 で生成)
  7. PORT はRailwayが自動設定
  8. デプロイが自動で開始される

Renderへのデプロイ(代替)

Render も人気のあるPaaSです。手順はほぼ同じです。

  1. https://render.com にアクセスしてアカウント作成
  2. 「New +」→「Web Service」を選択
  3. GitHubリポジトリを接続
  4. 設定:
  5. Build Command: npm install
  6. Start Command: node src/index.js
  7. Environment Variables を設定
  8. 「Create Web Service」をクリック

ヘルスチェックエンドポイント

デプロイ先のプラットフォームがサーバーの稼働状態を監視するために、ヘルスチェックエンドポイントを追加します。

// src/app.js に追加
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    environment: process.env.NODE_ENV || 'development',
  });
});

APIドキュメント(Swagger/OpenAPI)

APIを公開する際は、エンドポイントのドキュメントがあると利用者に親切です。Swagger UI を使えば、インタラクティブなドキュメントを自動生成できます。

> ブックマーク管理APIにSwagger UIを追加して。
  swagger-ui-express と swagger-jsdoc をインストールして。
  /api-docs で閲覧できるようにして。
npm install swagger-ui-express swagger-jsdoc
// src/swagger.js
const swaggerJsdoc = require('swagger-jsdoc');

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'Bookmark Manager API',
      version: '1.0.0',
      description: 'ブックマーク管理REST API',
    },
    servers: [
      { url: 'http://localhost:3000', description: '開発サーバー' },
    ],
    components: {
      securitySchemes: {
        bearerAuth: {
          type: 'http',
          scheme: 'bearer',
          bearerFormat: 'JWT',
        },
      },
      schemas: {
        Bookmark: {
          type: 'object',
          properties: {
            id: { type: 'integer', example: 1 },
            title: { type: 'string', example: 'Express公式' },
            url: { type: 'string', example: 'https://expressjs.com' },
            category: { type: 'string', example: '技術' },
            description: { type: 'string', example: 'Express.jsの公式サイト' },
            created_at: { type: 'string', example: '2025-01-01T00:00:00.000Z' },
            updated_at: { type: 'string', example: '2025-01-01T00:00:00.000Z' },
          },
        },
      },
    },
  },
  apis: ['./src/routes/*.js'],
};

const swaggerSpec = swaggerJsdoc(options);

module.exports = swaggerSpec;
// src/app.js に追加
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./swagger');

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));

ルートファイルにJSDocコメントで仕様を記述します。

/**
 * @swagger
 * /api/bookmarks:
 *   get:
 *     summary: ブックマーク一覧を取得
 *     tags: [Bookmarks]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: query
 *         name: category
 *         schema:
 *           type: string
 *         description: カテゴリで絞り込み
 *     responses:
 *       200:
 *         description: ブックマーク一覧
 */
router.get('/', (req, res) => {
  // ...
});

http://localhost:3000/api-docs にアクセスすると、Swagger UIが表示され、ブラウザ上から各エンドポイントを試すことができます。

デプロイ前のチェックリスト

本番環境にデプロイする前に確認すべき項目です。

[ ] .env がgitにコミットされていないこと
[ ] JWT_SECRET が十分に強力な値であること
[ ] NODE_ENV=production が設定されていること
[ ] CORS が特定オリジンに制限されていること
[ ] レート制限が有効であること
[ ] エラーレスポンスにスタックトレースが含まれないこと
[ ] ヘルスチェックエンドポイントがあること
[ ] 全テストが通ることnpm test
[ ] package.json  start スクリプトが正しいこと

実践ワーク

  1. dotenv を導入し、.env.env.example を作成する
  2. src/config.js に設定を一元化する
  3. helmetexpress-rate-limit を導入する
  4. ヘルスチェックエンドポイント(GET /health)を追加する
  5. Swagger UIを導入し、/api-docs でドキュメントが表示されることを確認する
  6. GitHubにリポジトリを作成し、コードをプッシュする
  7. RailwayまたはRenderにデプロイし、公開URLでAPIが動作することを確認する

まとめと講座の振り返り

今回のポイント: - 環境変数で秘密情報を管理し、.env はgitにコミットしない - helmetrate-limit、CORS制限で本番セキュリティを確保 - RailwayやRenderでGitHubから自動デプロイできる - Swagger UIでインタラクティブなAPIドキュメントを提供

講座全体の振り返り:

テーマ 学んだこと
1 Express入門 プロジェクト初期化、最初のサーバー起動
2 ルーティング REST設計、HTTPメソッド、express.Router
3 ミドルウェア リクエスト処理のパイプライン
4 CRUD操作 データ操作の4つの基本、レスポンス統一
5 データベース SQLite永続化、Prepared Statement
6 バリデーション Zodによる入力検証とサニタイズ
7 認証 JWT、bcrypt、保護されたルート
8 エラーハンドリング カスタムエラー、非同期エラーキャッチ
9 テスト Vitest + Supertest、自動テスト
10 デプロイ 環境変数、セキュリティ、公開

この講座で作った「ブックマーク管理API」は、Express.jsによるバックエンド開発の基本パターンを全て含んでいます。この知識をベースに、ユーザー管理システム、ToDoアプリ、ブログAPIなど、様々なAPIサーバーを構築できます。

参考文献: - Railway公式ドキュメント(https://docs.railway.app/) - Render公式ドキュメント(https://render.com/docs) - Swagger/OpenAPI仕様(https://swagger.io/specification/) - OWASP Node.js セキュリティチートシート(https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html)