Express を使った Node.js での REST API 構築

1. 概要

このガイドでは、Express を使用して Order を管理する REST API を構築する方法を説明します。プロジェクトの初期化、ルートの定義、ミドルウェア、データ検証、集中的なエラーハンドリングまでカバーしています。このガイドを読み終わったら、完全な CRUD 機能を持つ API を自分で構築でき、Jest と Supertest で自動テストを書き、Express で開発する際によくあるエラーを理解できるようになります。

このガイド全体を通じた例:Customer の Order を管理する API で、作成、取得、更新、削除をサポートしています。

2. プロジェクトのセットアップ

新しい Node.js プロジェクトを作成し、Express をインストールします:

mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest

package.json に type: module を追加して ES Module 構文の import/export を使い、テストスクリプトを宣言します:

{
  "name": "order-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

注意: ES Module を使用する Jest には --experimental-vm-modules フラグが必要です。面倒に感じる場合は、"type": "module" を削除して require/module.exports(CommonJS)を使用することもできます。どちらも有効で、チームの好みに応じて選択できます。

3. 基本的な Express サーバーを作成する

// src/app.js
import express from 'express';

const app = express();
app.use(express.json()); // JSON ボディを req.body にパースします

app.get('/health', (req, res) => {
  res.json({ status: 'UP' });
});

export default app;
// src/server.js
import app from './app.js';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Order API がポート ${PORT} で実行中です`);
});
  • express.json() は組み込みミドルウェアで、Express に Content-Type: application/json を自動的に req.body オブジェクトにパースさせます。この行がなければ、req.body は undefined になります。
  • テストを実行するときにポートを実際に開く必要がないよう、app.js を server.js から分離することが重要です。これは Node.js で REST API を開発する際の重要な習慣です。

テストしてみます:

node src/server.js
curl http://localhost:3000/health
{"status":"UP"}

4. Order リソースを定義する

API に集中するために、配列を使ったインメモリ「データベース」を使用します。実際のデータベースには依存しません。

// src/orders/orderRepository.js
let orders = [];
let nextId = 1;

export function findAll() {
  return orders;
}

export function findById(id) {
  return orders.find((o) => o.id === id);
}

export function create(data) {
  const order = { id: nextId++, ...data, status: 'PENDING' };
  orders.push(order);
  return order;
}

export function update(id, data) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return null;
  orders[index] = { ...orders[index], ...data };
  return orders[index];
}

export function remove(id) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return false;
  orders.splice(index, 1);
  return true;
}

このリポジトリは、任意の ORM(Sequelize、Prisma など)で見られるような CRUD 操作をシミュレートしています。唯一の違いは、データベースではなく RAM に保存されていることです。

5. Express Router を使ったルーティング

すべてのルートを app にアタッチするのではなく、express.Router() を使用してリソースごとにモジュール化します:

// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';

const router = Router();

router.get('/', (req, res) => {
  res.json(repo.findAll());
});

router.get('/:id', (req, res, next) => {
  const order = repo.findById(Number(req.params.id));
  if (!order) return next({ status: 404, message: 'Order が見つかりません' });
  res.json(order);
});

router.post('/', (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});

router.put('/:id', (req, res, next) => {
  const order = repo.update(Number(req.params.id), req.body);
  if (!order) return next({ status: 404, message: 'Order が見つかりません' });
  res.json(order);
});

router.delete('/:id', (req, res, next) => {
  const deleted = repo.remove(Number(req.params.id));
  if (!deleted) return next({ status: 404, message: 'Order が見つかりません' });
  res.status(204).end();
});

export default router;

ルータを /orders プレフィックス付きでアプリケーションにアタッチします:

// src/app.js (更新版)
import orderRouter from './orders/orderRouter.js';

app.use('/orders', orderRouter);
  • router.get('/:id', ...) — :id はルートパラメータで、req.params.id で取得します(常に文字列なので、比較のために Number() が必要です)。
  • エラーハンドリングミドルウェア(セクション 7)に制御を移すために next({ status: 404, message: ... }) を呼び出します。各ルートで res.status(404)... を繰り返す代わりに。
  • DELETE が成功した場合、204 No Content でボディを返さず。

6. 入力データの検証

if/else を使った手動検証は、ケースを見落とすのは簡単です。express-validator を使うと、すっきりして明確になります:

npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';

export const validateCreateOrder = [
  body('customerName').isString().notEmpty().withMessage('customerName は必須です'),
  body('amount').isFloat({ gt: 0 }).withMessage('amount は 0 より大きい必要があります'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
];

POST ルートに検証ミドルウェアを追加します:

// src/orders/orderRouter.js (更新版)
import { validateCreateOrder } from './orderValidator.js';

router.post('/', validateCreateOrder, (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});
  • 各 body(...) は 1 つのフィールドをチェックするルールです。チェックに失敗すると、エラーが validationResult(req) に集約されます。
  • 配列内の最後のミドルウェアは errors.isEmpty() をチェックします。エラーがある場合、400 を返して詳細を含め、実際のハンドラにリクエストを進める next() は呼び出しません。

amount が足りないリクエストを送信します:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName": "Nam"}'
{"errors":[{"type":"field","msg":"amount は 0 より大きい必要があります","path":"amount",...}]}

7. 集中的なエラーハンドリング

Express では、エラーハンドリングミドルウェアを定義できます。これは 4 つのパラメータ (err, req, res, next) を持つミドルウェアで、すべてのルートの後に配置します:

// src/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const message = err.message || 'システムエラー';

  if (status === 500) {
    console.error(err); // デバッグ用に実際のエラーをログし、スタックトレースをクライアントに返さない
  }

  res.status(status).json({ message });
}
// src/app.js (更新版、ファイルの最後に追加)
import { errorHandler } from './errorHandler.js';

app.use(errorHandler);
  • Express は 4 つのパラメータ数でこれをエラーハンドラとして認識します。関数名ではなく。
  • app.use(errorHandler) は 最後に 配置する必要があります。すべての app.use(router) の後です。Express はミドルウェアを宣言順に処理します。
  • すべての next(err) またはルート内でスローされたエラー(express-async-errors を使用するか try/catch を使用する場合)がここに渡されます。

一般的なエラー: async コード内で next(err) を呼び出さない(例:await db.query(...) がスローする)と、Express がエラーをキャッチできず、サーバーがクラッシュするか、リクエストが応答しなくなります。Express 5(新しいバージョン)では、async ハンドラ内でスローされたエラーは自動的にキャッチされます。Express 4 では、手動で try/catch をラップして next(err) を呼び出す必要があります。

8. ビジネスロジックを分離するために Service レイヤーを追加する

ロジックがより複雑になった場合(例:顧客の amount が制限を超える場合は Order を作成させない)、すべてをルータに詰め込まないでください。ロジックを orderService に分離します:

// src/orders/orderService.js
import * as repo from './orderRepository.js';

const MAX_AMOUNT = 100_000_000;

export function createOrder(data) {
  if (data.amount > MAX_AMOUNT) {
    const err = new Error('許可された限度額を超えています');
    err.status = 422;
    throw err;
  }
  return repo.create(data);
}
// src/orders/orderRouter.js (POST ルートの更新版)
import * as orderService from './orderService.js';

router.post('/', validateCreateOrder, (req, res, next) => {
  try {
    const order = orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

ルータは HTTP(ステータスコード、パラメータ解析)を処理し、orderService はビジネスロジックを処理します。このパターンは他のフレームワークの Controller–Service レイヤーと似ており、HTTP に依存せずにビジネスロジックを独立してテストするのに役立ちます。

9. Jest と Supertest を使ったテストの作成

Supertest を使うと、実際にポートを開くことなく Express インスタンス上で API を呼び出せます。これが、セクション 3 で app.js を server.js から分離した理由です。

// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';

describe('Order API', () => {
  it('Order の作成に成功し、201 と id を返す', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam', amount: 500000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      customerName: 'Nam',
      amount: 500000,
      status: 'PENDING',
    });
    expect(response.body.id).toBeDefined();
  });

  it('amount がない場合は 400 を返す', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam' });

    expect(response.status).toBe(400);
  });

  it('存在しない Order を取得すると 404 を返す', async () => {
    const response = await request(app).get('/orders/9999');

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('Order が見つかりません');
  });
});

テストを実行します:

npm test
PASS  tests/order.test.js
  Order API
    ✓ Order の作成に成功し、201 と id を返す
    ✓ amount がない場合は 400 を返す
    ✓ 存在しない Order を取得すると 404 を返す
  • request(app) は HTTP クライアントを初期化し、Express app に直接バインドされます。実際のネットワーク経由のテストより高速です。
  • toMatchObject は指定されたフィールドだけをチェックし、他のフィールド(自動生成される id など)を無視します。動的な値を持つレスポンスをテストするのに便利です。

注意: orders はテスト間で共有されるインメモリ配列なので、テストの実行順序に応じて相互に影響することがあります(例:テスト 1 で作成された Order はテスト 2 の実行時にも存在します)。実際の API がデータベースに接続されている場合は、beforeEach でデータをリセットするか、トランザクション ロールバックを使用してください。

10. Express を素のままで使うべきでない場合

  • 大規模な API、複数の複雑なドメイン: Express には強制的なアーキテクチャ上の約束がないため、チームが自分たちのルールを設定しなければ「スパゲッティ コード」が生まれやすいです。Spring に似た DI、モジュール、デコレータを持つ NestJS を検討してください。チームが明確なレイヤード アーキテクチャに慣れていれば。
  • 強いタイプセーフが必要な場合: Express + 素の JavaScript はコンパイル時にデータ型エラーをキャッチできません。大きなプロジェクトの場合は、最初から TypeScript を使用するべきです。後で変換すると、コストがはるかに高くなります。
  • 高スループット、複雑なストリーミング/WebSocket が必要な場合: Express でも実行できますが、Fastify または他のパフォーマンス最適化フレームワーク(JSON シリアライゼーション ベンチマークが速い)は、極めて大きなトラフィックのシステムに適しているかもしれません。

小規模から中規模の API で、ガイドの例のように明確な CRUD 機能があれば、Express は依然として高速でシンプルな選択肢です。大規模なコミュニティがあり、ミドルウェア(認証、レート制限、CORS など)が豊富に存在します。

11. まとめ

このガイドを通じて、Express で完全な Order REST API を構築できるようになります。Router を使ったルーティング、express-validator を使った入力データ検証、4 つのパラメータを持つミドルウェアを使った集中的なエラーハンドリング、そしてビジネスロジックを Service レイヤーに分離してテストしやすくしています。Jest + Supertest を使ったテストにより、API の動作を破壊することなく自信を持ってリファクタリングできます。この基盤から、実際のデータベースの接続(Prisma、Mongoose)、認証(JWT)、または TypeScript への移行など、プロジェクトが成長に応じて追加できます。

nodejsexpressrest-apijavascripttesting