Express.jsでREST API構築入門|ルーティング・ミドルウェア・エラーハンドリング

kento_morota 27分で読めます

Express.jsは、Node.jsで最も広く使われているWebアプリケーションフレームワークです。シンプルなAPIから大規模なWebサービスまで、柔軟に対応できる軽量さが特徴です。

本記事では、Express.jsを使ったREST APIの構築方法を、プロジェクトセットアップからルーティング、ミドルウェア、エラーハンドリング、認証まで段階的に解説します。

Express.jsとREST APIの基本

REST(Representational State Transfer)は、HTTPメソッド(GET/POST/PUT/DELETE)を使ってリソースを操作するAPIの設計思想です。Express.jsはこのRESTfulなAPIを簡潔に記述するための機能を提供します。

プロジェクトセットアップ

# プロジェクト初期化
mkdir express-api && cd express-api
pnpm init

# 必要なパッケージのインストール
pnpm add express cors
pnpm add -D typescript @types/express @types/cors @types/node tsx
npx tsc --init
// package.json
{
  "name": "express-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

基本的なサーバー構築

// src/index.ts
import express from "express";
import cors from "cors";

const app = express();

// 基本ミドルウェア
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// ヘルスチェック
app.get("/api/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

export default app;

ルーティングの設計と実装

RESTful APIでは、URLとHTTPメソッドの組み合わせでリソースの操作を表現します。

基本的なCRUDルーティング

// src/types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  createdAt: string;
  updatedAt: string;
}

export type UserCreateInput = Omit<User, "id" | "createdAt" | "updatedAt">;
export type UserUpdateInput = Partial<UserCreateInput>;
// src/routes/users.ts
import { Router, Request, Response } from "express";
import type { User, UserCreateInput, UserUpdateInput } from "../types/user.js";

const router = Router();

// インメモリデータストア(実務ではDBを使用)
let users: User[] = [
  {
    id: 1,
    name: "田中太郎",
    email: "tanaka@example.com",
    role: "admin",
    createdAt: "2026-01-01T00:00:00Z",
    updatedAt: "2026-01-01T00:00:00Z",
  },
];
let nextId = 2;

// GET /api/users - ユーザー一覧取得
router.get("/", (req: Request, res: Response) => {
  const { role, search } = req.query;

  let result = [...users];

  if (typeof role === "string") {
    result = result.filter((u) => u.role === role);
  }
  if (typeof search === "string") {
    const keyword = search.toLowerCase();
    result = result.filter(
      (u) =>
        u.name.toLowerCase().includes(keyword) ||
        u.email.toLowerCase().includes(keyword)
    );
  }

  res.json({
    success: true,
    data: result,
    total: result.length,
  });
});

// GET /api/users/:id - ユーザー詳細取得
router.get("/:id", (req: Request, res: Response) => {
  const id = parseInt(req.params.id, 10);
  const user = users.find((u) => u.id === id);

  if (!user) {
    return res.status(404).json({
      success: false,
      error: { code: "NOT_FOUND", message: "ユーザーが見つかりません" },
    });
  }

  res.json({ success: true, data: user });
});

// POST /api/users - ユーザー作成
router.post("/", (req: Request, res: Response) => {
  const input: UserCreateInput = req.body;

  const now = new Date().toISOString();
  const newUser: User = {
    ...input,
    id: nextId++,
    createdAt: now,
    updatedAt: now,
  };

  users.push(newUser);
  res.status(201).json({ success: true, data: newUser });
});

// PUT /api/users/:id - ユーザー更新
router.put("/:id", (req: Request, res: Response) => {
  const id = parseInt(req.params.id, 10);
  const index = users.findIndex((u) => u.id === id);

  if (index === -1) {
    return res.status(404).json({
      success: false,
      error: { code: "NOT_FOUND", message: "ユーザーが見つかりません" },
    });
  }

  const update: UserUpdateInput = req.body;
  users[index] = {
    ...users[index],
    ...update,
    updatedAt: new Date().toISOString(),
  };

  res.json({ success: true, data: users[index] });
});

// DELETE /api/users/:id - ユーザー削除
router.delete("/:id", (req: Request, res: Response) => {
  const id = parseInt(req.params.id, 10);
  const index = users.findIndex((u) => u.id === id);

  if (index === -1) {
    return res.status(404).json({
      success: false,
      error: { code: "NOT_FOUND", message: "ユーザーが見つかりません" },
    });
  }

  users.splice(index, 1);
  res.status(204).send();
});

export default router;

ルーターの統合

// src/index.ts にルーターを追加
import userRouter from "./routes/users.js";

app.use("/api/users", userRouter);

ミドルウェアの仕組みと実装

ミドルウェアは、リクエストとレスポンスの間に挟まる処理です。Express.jsの核心的な概念であり、ロギング、認証、バリデーション、エラーハンドリングなどを横断的に適用できます。

リクエストログミドルウェア

// src/middleware/logger.ts
import { Request, Response, NextFunction } from "express";

export function requestLogger(req: Request, res: Response, next: NextFunction): void {
  const start = Date.now();

  // レスポンス完了時にログ出力
  res.on("finish", () => {
    const duration = Date.now() - start;
    const log = {
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: `${duration}ms`,
      timestamp: new Date().toISOString(),
    };
    console.log(JSON.stringify(log));
  });

  next();
}

バリデーションミドルウェア(Zod連携)

// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";

interface ValidateTarget {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

export function validate(schemas: ValidateTarget) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const errors: Array<{ field: string; message: string }> = [];

    for (const [target, schema] of Object.entries(schemas)) {
      if (!schema) continue;
      const data = req[target as keyof typeof schemas];
      const result = schema.safeParse(data);

      if (!result.success) {
        for (const issue of result.error.issues) {
          errors.push({
            field: `${target}.${issue.path.join(".")}`,
            message: issue.message,
          });
        }
      } else {
        // パース結果で上書き(transformやdefaultが反映される)
        (req as any)[target] = result.data;
      }
    }

    if (errors.length > 0) {
      res.status(400).json({
        success: false,
        error: { code: "VALIDATION_ERROR", message: "入力値が不正です", details: errors },
      });
      return;
    }

    next();
  };
}
// ルートでの使用例
import { z } from "zod";
import { validate } from "../middleware/validate.js";

const createUserSchema = z.object({
  name: z.string().min(1, "名前は必須です").max(100),
  email: z.string().email("メールアドレスの形式が不正です"),
  role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});

router.post(
  "/",
  validate({ body: createUserSchema }),
  (req: Request, res: Response) => {
    // req.body はバリデーション済み
    const input = req.body;
    // ...
  }
);

レート制限ミドルウェア

// src/middleware/rateLimit.ts
import { Request, Response, NextFunction } from "express";

interface RateLimitStore {
  [key: string]: { count: number; resetAt: number };
}

export function rateLimit(options: { windowMs: number; max: number }) {
  const store: RateLimitStore = {};

  return (req: Request, res: Response, next: NextFunction): void => {
    const key = req.ip || "unknown";
    const now = Date.now();

    if (!store[key] || store[key].resetAt < now) {
      store[key] = { count: 0, resetAt: now + options.windowMs };
    }

    store[key].count++;

    const remaining = Math.max(0, options.max - store[key].count);
    res.set("X-RateLimit-Limit", String(options.max));
    res.set("X-RateLimit-Remaining", String(remaining));
    res.set("X-RateLimit-Reset", String(store[key].resetAt));

    if (store[key].count > options.max) {
      res.status(429).json({
        success: false,
        error: { code: "RATE_LIMIT", message: "リクエスト数が上限を超えました" },
      });
      return;
    }

    next();
  };
}

// 適用例:1分あたり60リクエストまで
app.use("/api/", rateLimit({ windowMs: 60 * 1000, max: 60 }));

エラーハンドリング

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

カスタムエラークラス

// src/errors/AppError.ts
export class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public code: string = "INTERNAL_ERROR"
  ) {
    super(message);
    this.name = "AppError";
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource}が見つかりません`, 404, "NOT_FOUND");
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = "認証が必要です") {
    super(message, 401, "UNAUTHORIZED");
  }
}

export class ForbiddenError extends AppError {
  constructor(message = "アクセス権限がありません") {
    super(message, 403, "FORBIDDEN");
  }
}

export class ConflictError extends AppError {
  constructor(message: string) {
    super(message, 409, "CONFLICT");
  }
}

グローバルエラーハンドラー

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../errors/AppError.js";

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // AppErrorの場合はステータスコードとメッセージを使用
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message,
      },
    });
    return;
  }

  // 予期しないエラー
  console.error("Unexpected error:", err);
  res.status(500).json({
    success: false,
    error: {
      code: "INTERNAL_ERROR",
      message:
        process.env.NODE_ENV === "production"
          ? "サーバー内部エラーが発生しました"
          : err.message,
    },
  });
}

// 404ハンドラー
export function notFoundHandler(req: Request, res: Response): void {
  res.status(404).json({
    success: false,
    error: {
      code: "NOT_FOUND",
      message: `${req.method} ${req.path} は存在しません`,
    },
  });
}
// src/index.ts にエラーハンドラーを登録(ルーターの後に配置)
import { errorHandler, notFoundHandler } from "./middleware/errorHandler.js";

app.use("/api/users", userRouter);

// 404ハンドラー(すべてのルートの後)
app.use(notFoundHandler);

// エラーハンドラー(最後に配置)
app.use(errorHandler);

非同期エラーのキャッチ

// asyncHandler ラッパー
import { Request, Response, NextFunction } from "express";

function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
}

// 使用例
router.get(
  "/:id",
  asyncHandler(async (req, res) => {
    const id = parseInt(req.params.id, 10);
    const user = await findUserById(id); // エラーが投げられたら自動でnextに渡る

    if (!user) {
      throw new NotFoundError("ユーザー");
    }

    res.json({ success: true, data: user });
  })
);

認証・認可の実装

JWT認証ミドルウェア

// src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { UnauthorizedError, ForbiddenError } from "../errors/AppError.js";

interface JwtPayload {
  userId: number;
  role: string;
}

// Requestオブジェクトにuserを追加
declare global {
  namespace Express {
    interface Request {
      user?: JwtPayload;
    }
  }
}

const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";

// 認証ミドルウェア
export function authenticate(req: Request, res: Response, next: NextFunction): void {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    throw new UnauthorizedError("Bearerトークンが必要です");
  }

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

  try {
    const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
    req.user = payload;
    next();
  } catch (error) {
    throw new UnauthorizedError("トークンが無効です");
  }
}

// 認可ミドルウェア(ロールベース)
export function authorize(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user) {
      throw new UnauthorizedError();
    }

    if (!roles.includes(req.user.role)) {
      throw new ForbiddenError(`この操作には${roles.join("または")}権限が必要です`);
    }

    next();
  };
}

// ログインエンドポイント
router.post("/login", asyncHandler(async (req, res) => {
  const { email, password } = req.body;

  const user = await authenticateUser(email, password);
  if (!user) {
    throw new UnauthorizedError("メールアドレスまたはパスワードが正しくありません");
  }

  const token = jwt.sign(
    { userId: user.id, role: user.role },
    JWT_SECRET,
    { expiresIn: "24h" }
  );

  res.json({ success: true, data: { token, user } });
}));

認証・認可の適用

// 認証が必要なルート
router.get("/me", authenticate, asyncHandler(async (req, res) => {
  const user = await findUserById(req.user!.userId);
  res.json({ success: true, data: user });
}));

// 管理者のみアクセス可能なルート
router.delete(
  "/:id",
  authenticate,
  authorize("admin"),
  asyncHandler(async (req, res) => {
    await deleteUser(parseInt(req.params.id, 10));
    res.status(204).send();
  })
);

プロジェクト構成のベストプラクティス

推奨ディレクトリ構成

src/
├── index.ts              # アプリケーションのエントリポイント
├── app.ts                # Expressアプリケーション設定
├── routes/
│   ├── index.ts          # ルーター統合
│   ├── users.ts          # ユーザー関連ルート
│   └── auth.ts           # 認証関連ルート
├── middleware/
│   ├── logger.ts
│   ├── auth.ts
│   ├── validate.ts
│   ├── rateLimit.ts
│   └── errorHandler.ts
├── services/
│   ├── userService.ts    # ビジネスロジック
│   └── authService.ts
├── repositories/
│   └── userRepository.ts # データアクセス
├── errors/
│   └── AppError.ts
├── types/
│   └── user.ts
└── utils/
    └── helpers.ts

サービス層の分離

// src/services/userService.ts
import type { User, UserCreateInput, UserUpdateInput } from "../types/user.js";
import { NotFoundError, ConflictError } from "../errors/AppError.js";

export class UserService {
  async findAll(filter?: { role?: string; search?: string }): Promise<User[]> {
    // データベースクエリ
    return [];
  }

  async findById(id: number): Promise<User> {
    const user = await db.user.findUnique({ where: { id } });
    if (!user) throw new NotFoundError("ユーザー");
    return user;
  }

  async create(input: UserCreateInput): Promise<User> {
    const existing = await db.user.findUnique({ where: { email: input.email } });
    if (existing) throw new ConflictError("このメールアドレスは既に使用されています");

    return db.user.create({ data: input });
  }

  async update(id: number, input: UserUpdateInput): Promise<User> {
    await this.findById(id); // 存在確認
    return db.user.update({ where: { id }, data: input });
  }

  async delete(id: number): Promise<void> {
    await this.findById(id); // 存在確認
    await db.user.delete({ where: { id } });
  }
}

まとめ

Express.jsを使ったREST API構築のポイントを整理します。

ルーティング:Routerを使ってリソースごとにファイルを分割し、RESTful な URL設計(GET/POST/PUT/DELETE)に沿ったエンドポイントを定義します。

ミドルウェア:ロギング、バリデーション(Zod連携)、レート制限、認証など、横断的な処理はミドルウェアとして実装し、再利用性を高めます。

エラーハンドリング:カスタムエラークラスを定義し、グローバルエラーハンドラーで一元的に処理します。asyncHandlerで非同期エラーも確実にキャッチします。

認証・認可:JWTを使った認証ミドルウェアとロールベースの認可ミドルウェアで、APIのセキュリティを確保します。

プロジェクト構成:ルート、ミドルウェア、サービス、リポジトリ、エラーを適切に分離し、責務の明確なコードを維持します。

Express.jsはシンプルですが拡張性が高く、ミドルウェアを組み合わせることで堅牢なAPIを構築できます。まずは基本的なCRUDエンドポイントから始め、段階的にバリデーションや認証を追加していきましょう。

#Express.js#REST API#Node.js
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう