HTTPサーバーを構築する際、クライアントからのリクエストを適切なハンドラーに振り分ける「ルーティング」は必須の機能です。ExpressやFastifyなどのフレームワークはこの機能を提供しますが、Node.jsの組み込みAPIだけでも十分に実装できます。

この記事では、WHATWG URL APIを使用したURL解析、URLSearchParamsによるクエリパラメータ処理、そしてパスベースのルーティングシステムを自作する方法を解説します。フレームワークの内部動作を理解することで、より効率的なバックエンド開発が可能になります。

実行環境と前提条件

項目 バージョン
Node.js 20.x LTS以上
npm 10.x以上
OS Windows/macOS/Linux

前提条件として、JavaScriptの基礎知識とNode.jsの基本的なAPI理解があることを想定しています。HTTPサーバーの基本的な作成方法については、前回の記事「Node.js httpモジュール入門」を参照してください。

URLクラスによるURL解析

WHATWG URL APIの概要

Node.jsでは、Webブラウザと互換性のあるWHATWG URL Standardに準拠したURLクラスがグローバルに提供されています。このクラスを使用することで、URL文字列を構造化されたオブジェクトとしてパースできます。

1
2
3
4
5
6
7
8
9
// URLクラスはグローバルに利用可能(importは不要)
const url = new URL('https://example.com:8080/api/users?page=1&limit=10#section');

console.log(url.protocol);  // 'https:'
console.log(url.hostname);  // 'example.com'
console.log(url.port);      // '8080'
console.log(url.pathname);  // '/api/users'
console.log(url.search);    // '?page=1&limit=10'
console.log(url.hash);      // '#section'

URLクラスは以下のプロパティを提供します。

プロパティ 説明
href 完全なURL文字列 https://example.com:8080/api/users?page=1#section
origin プロトコル + ホスト + ポート https://example.com:8080
protocol プロトコル(コロン含む) https:
hostname ホスト名(ポートなし) example.com
host ホスト名 + ポート example.com:8080
port ポート番号(文字列) 8080
pathname パス部分 /api/users
search クエリ文字列(?含む) ?page=1&limit=10
searchParams URLSearchParamsオブジェクト URLSearchParams { 'page' => '1', 'limit' => '10' }
hash フラグメント(#含む) #section

HTTPリクエストからURLを解析する

http.createServer()のリクエストオブジェクトにはurlプロパティがありますが、これはパス部分のみを含む相対URLです。完全なURLオブジェクトを取得するには、ベースURLと組み合わせてURLクラスでパースします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const http = require('node:http');

const server = http.createServer((req, res) => {
  // req.urlは相対パス(例: '/api/users?page=1')
  // 完全なURLを構築するにはベースURLが必要
  const baseUrl = `http://${req.headers.host}`;
  const url = new URL(req.url, baseUrl);

  console.log('パス:', url.pathname);
  console.log('クエリ:', url.search);
  console.log('メソッド:', req.method);

  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    method: req.method,
    pathname: url.pathname,
    query: Object.fromEntries(url.searchParams)
  }));
});

server.listen(3000, () => {
  console.log('サーバーが http://localhost:3000 で起動しました');
});

URL.canParse()による検証

Node.js 19.9.0以降では、URL.canParse()メソッドを使用してURL文字列が有効かどうかを事前に検証できます。無効なURLをパースしようとするとTypeErrorがスローされるため、この検証は重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// URLの有効性を検証
console.log(URL.canParse('https://example.com/api'));     // true
console.log(URL.canParse('/api/users', 'https://example.com')); // true
console.log(URL.canParse('invalid-url'));                  // false

// 安全なURLパース関数
function safeParseUrl(urlString, base) {
  if (!URL.canParse(urlString, base)) {
    return null;
  }
  return new URL(urlString, base);
}

const url = safeParseUrl('/api/users', 'https://example.com');
if (url) {
  console.log(url.pathname); // '/api/users'
}

URLSearchParamsによるクエリパラメータ処理

URLSearchParamsの基本操作

URLSearchParamsクラスは、URLのクエリ文字列を操作するためのAPIを提供します。URLオブジェクトのsearchParamsプロパティから取得するか、直接インスタンスを作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// URLオブジェクトから取得
const url = new URL('https://example.com/search?q=nodejs&page=1&limit=10');
const params = url.searchParams;

// パラメータの取得
console.log(params.get('q'));      // 'nodejs'
console.log(params.get('page'));   // '1'
console.log(params.get('unknown')); // null

// パラメータの存在確認
console.log(params.has('q'));      // true
console.log(params.has('sort'));   // false

// すべてのパラメータを取得
console.log(params.toString());    // 'q=nodejs&page=1&limit=10'

複数値を持つパラメータの処理

同じキーに複数の値が設定されている場合、get()は最初の値のみを返します。すべての値を取得するにはgetAll()を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const url = new URL('https://example.com/filter?tag=javascript&tag=nodejs&tag=typescript');
const params = url.searchParams;

// 最初の値のみ取得
console.log(params.get('tag'));     // 'javascript'

// すべての値を取得
console.log(params.getAll('tag'));  // ['javascript', 'nodejs', 'typescript']

// イテレーションですべてのキー・値ペアを処理
for (const [key, value] of params) {
  console.log(`${key}: ${value}`);
}
// 出力:
// tag: javascript
// tag: nodejs
// tag: typescript

パラメータの追加・更新・削除

URLSearchParamsはミュータブルなオブジェクトで、パラメータの追加、更新、削除が可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const params = new URLSearchParams();

// パラメータの追加(append)- 同じキーに複数値を追加可能
params.append('tag', 'javascript');
params.append('tag', 'nodejs');
console.log(params.toString()); // 'tag=javascript&tag=nodejs'

// パラメータの設定(set)- 既存の値を上書き
params.set('page', '1');
params.set('tag', 'typescript'); // 'tag'のすべての値を置き換え
console.log(params.toString()); // 'tag=typescript&page=1'

// パラメータの削除
params.delete('tag');
console.log(params.toString()); // 'page=1'

// パラメータ数の取得
console.log(params.size); // 1

オブジェクトとの相互変換

クエリパラメータをJavaScriptオブジェクトに変換したり、オブジェクトからクエリ文字列を生成したりする方法を紹介します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// URLSearchParamsからオブジェクトへ変換
const url = new URL('https://example.com/api?page=1&limit=10&sort=desc');
const paramsObject = Object.fromEntries(url.searchParams);
console.log(paramsObject);
// { page: '1', limit: '10', sort: 'desc' }

// オブジェクトからURLSearchParamsを作成
const queryObject = {
  page: 2,
  limit: 20,
  filter: 'active'
};
const params = new URLSearchParams(queryObject);
console.log(params.toString());
// 'page=2&limit=20&filter=active'

// 配列を含むオブジェクトの場合(注意:配列はカンマ区切りの文字列になる)
const withArray = new URLSearchParams({ tags: ['js', 'node'] });
console.log(withArray.toString()); // 'tags=js%2Cnode'

// 複数値が必要な場合は配列形式で渡す
const multiValue = new URLSearchParams([
  ['tag', 'javascript'],
  ['tag', 'nodejs']
]);
console.log(multiValue.toString()); // 'tag=javascript&tag=nodejs'

実践的なクエリパラメータパーサー

ページネーションやフィルタリングで使用する実践的なパラメータパーサーを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
 * クエリパラメータをパースして型変換を行う
 * @param {URLSearchParams} searchParams
 * @returns {Object} パース済みのパラメータオブジェクト
 */
function parseQueryParams(searchParams) {
  const result = {
    page: 1,
    limit: 10,
    sort: 'createdAt',
    order: 'desc',
    filters: {}
  };

  // ページネーションパラメータ
  const page = searchParams.get('page');
  if (page && !isNaN(Number(page))) {
    result.page = Math.max(1, parseInt(page, 10));
  }

  const limit = searchParams.get('limit');
  if (limit && !isNaN(Number(limit))) {
    result.limit = Math.min(100, Math.max(1, parseInt(limit, 10)));
  }

  // ソートパラメータ
  const sort = searchParams.get('sort');
  if (sort && /^[a-zA-Z_]+$/.test(sort)) {
    result.sort = sort;
  }

  const order = searchParams.get('order');
  if (order === 'asc' || order === 'desc') {
    result.order = order;
  }

  // フィルターパラメータ(filter[key]=value 形式)
  for (const [key, value] of searchParams) {
    const filterMatch = key.match(/^filter\[(\w+)\]$/);
    if (filterMatch) {
      result.filters[filterMatch[1]] = value;
    }
  }

  return result;
}

// 使用例
const url = new URL('https://api.example.com/users?page=2&limit=20&sort=name&order=asc&filter[status]=active&filter[role]=admin');
const params = parseQueryParams(url.searchParams);
console.log(params);
// {
//   page: 2,
//   limit: 20,
//   sort: 'name',
//   order: 'asc',
//   filters: { status: 'active', role: 'admin' }
// }

パスベースのルーティング実装

シンプルなルーター設計

HTTPリクエストのメソッドとパスに基づいてハンドラーを呼び出すシンプルなルーターを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
const http = require('node:http');

class Router {
  constructor() {
    this.routes = [];
  }

  /**
   * ルートを追加する
   * @param {string} method HTTPメソッド
   * @param {string} path パス
   * @param {Function} handler ハンドラー関数
   */
  addRoute(method, path, handler) {
    this.routes.push({ method: method.toUpperCase(), path, handler });
  }

  // HTTPメソッドのショートカット
  get(path, handler) {
    this.addRoute('GET', path, handler);
  }

  post(path, handler) {
    this.addRoute('POST', path, handler);
  }

  put(path, handler) {
    this.addRoute('PUT', path, handler);
  }

  delete(path, handler) {
    this.addRoute('DELETE', path, handler);
  }

  /**
   * リクエストにマッチするルートを検索する
   * @param {string} method HTTPメソッド
   * @param {string} pathname リクエストパス
   * @returns {Object|null} マッチしたルートまたはnull
   */
  findRoute(method, pathname) {
    return this.routes.find(
      route => route.method === method && route.path === pathname
    ) || null;
  }

  /**
   * リクエストハンドラーを返す
   * @returns {Function} HTTPリクエストハンドラー
   */
  handler() {
    return (req, res) => {
      const baseUrl = `http://${req.headers.host}`;
      const url = new URL(req.url, baseUrl);
      const route = this.findRoute(req.method, url.pathname);

      if (route) {
        route.handler(req, res, { url, params: {}, query: url.searchParams });
      } else {
        res.writeHead(404, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Not Found' }));
      }
    };
  }
}

// 使用例
const router = new Router();

router.get('/api/users', (req, res, { query }) => {
  const page = query.get('page') || '1';
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ message: 'ユーザー一覧', page }));
});

router.post('/api/users', (req, res) => {
  res.writeHead(201, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ message: 'ユーザーを作成しました' }));
});

router.get('/api/health', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok' }));
});

const server = http.createServer(router.handler());
server.listen(3000, () => {
  console.log('サーバーが http://localhost:3000 で起動しました');
});

正規表現によるパスパラメータ抽出

動的ルーティングの実装

/api/users/:idのような動的パラメータを含むルートをサポートするルーターを実装します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
const http = require('node:http');

class DynamicRouter {
  constructor() {
    this.routes = [];
  }

  /**
   * パスパターンを正規表現に変換する
   * @param {string} pattern パスパターン(例: '/users/:id')
   * @returns {Object} 正規表現とパラメータ名の配列
   */
  pathToRegex(pattern) {
    const paramNames = [];
    
    // :paramName 形式のパラメータを抽出
    const regexPattern = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
      paramNames.push(paramName);
      return '([^/]+)'; // パラメータ値にマッチする正規表現
    });

    return {
      regex: new RegExp(`^${regexPattern}$`),
      paramNames
    };
  }

  /**
   * ルートを追加する
   * @param {string} method HTTPメソッド
   * @param {string} pattern パスパターン
   * @param {Function} handler ハンドラー関数
   */
  addRoute(method, pattern, handler) {
    const { regex, paramNames } = this.pathToRegex(pattern);
    this.routes.push({
      method: method.toUpperCase(),
      pattern,
      regex,
      paramNames,
      handler
    });
  }

  get(pattern, handler) {
    this.addRoute('GET', pattern, handler);
  }

  post(pattern, handler) {
    this.addRoute('POST', pattern, handler);
  }

  put(pattern, handler) {
    this.addRoute('PUT', pattern, handler);
  }

  delete(pattern, handler) {
    this.addRoute('DELETE', pattern, handler);
  }

  patch(pattern, handler) {
    this.addRoute('PATCH', pattern, handler);
  }

  /**
   * リクエストにマッチするルートを検索する
   * @param {string} method HTTPメソッド
   * @param {string} pathname リクエストパス
   * @returns {Object|null} マッチしたルートとパラメータ
   */
  findRoute(method, pathname) {
    for (const route of this.routes) {
      if (route.method !== method) continue;

      const match = pathname.match(route.regex);
      if (match) {
        const params = {};
        route.paramNames.forEach((name, index) => {
          params[name] = decodeURIComponent(match[index + 1]);
        });
        return { route, params };
      }
    }
    return null;
  }

  /**
   * リクエストハンドラーを返す
   * @returns {Function} HTTPリクエストハンドラー
   */
  handler() {
    return (req, res) => {
      const baseUrl = `http://${req.headers.host}`;
      const url = new URL(req.url, baseUrl);
      const result = this.findRoute(req.method, url.pathname);

      if (result) {
        const context = {
          url,
          params: result.params,
          query: url.searchParams
        };
        result.route.handler(req, res, context);
      } else {
        res.writeHead(404, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Not Found', path: url.pathname }));
      }
    };
  }
}

// 使用例
const router = new DynamicRouter();

// 静的ルート
router.get('/api/users', (req, res, { query }) => {
  const page = query.get('page') || '1';
  const limit = query.get('limit') || '10';
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    message: 'ユーザー一覧を取得しました',
    pagination: { page: Number(page), limit: Number(limit) }
  }));
});

// 動的ルート - 単一パラメータ
router.get('/api/users/:id', (req, res, { params }) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    message: `ユーザー ${params.id} を取得しました`,
    userId: params.id
  }));
});

// 動的ルート - 複数パラメータ
router.get('/api/users/:userId/posts/:postId', (req, res, { params }) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    message: '投稿を取得しました',
    userId: params.userId,
    postId: params.postId
  }));
});

// PUT - リソース更新
router.put('/api/users/:id', (req, res, { params }) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    message: `ユーザー ${params.id} を更新しました`
  }));
});

// DELETE - リソース削除
router.delete('/api/users/:id', (req, res, { params }) => {
  res.writeHead(204);
  res.end();
});

const server = http.createServer(router.handler());
server.listen(3000, () => {
  console.log('サーバーが http://localhost:3000 で起動しました');
});

パスパターンマッチングの詳細

正規表現によるパスマッチングの動作を詳しく見ていきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
 * パスパターンマッチングのデモンストレーション
 */
function demonstratePathMatching() {
  // パターン: /api/users/:id
  // 正規表現に変換: /^\/api\/users\/([^/]+)$/

  const pattern = '/api/users/:id';
  const paramNames = [];
  
  const regexPattern = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
    paramNames.push(name);
    return '([^/]+)';
  });
  
  const regex = new RegExp(`^${regexPattern}$`);
  console.log('パターン:', pattern);
  console.log('正規表現:', regex);
  console.log('パラメータ名:', paramNames);

  // テストケース
  const testPaths = [
    '/api/users/123',
    '/api/users/abc-def',
    '/api/users/',
    '/api/users',
    '/api/users/123/posts'
  ];

  testPaths.forEach(path => {
    const match = path.match(regex);
    if (match) {
      const params = {};
      paramNames.forEach((name, i) => {
        params[name] = match[i + 1];
      });
      console.log(`✓ ${path} => params:`, params);
    } else {
      console.log(`✗ ${path} => マッチしません`);
    }
  });
}

demonstratePathMatching();
// 出力:
// パターン: /api/users/:id
// 正規表現: /^\/api\/users\/([^/]+)$/
// パラメータ名: [ 'id' ]
// ✓ /api/users/123 => params: { id: '123' }
// ✓ /api/users/abc-def => params: { id: 'abc-def' }
// ✗ /api/users/ => マッチしません
// ✗ /api/users => マッチしません
// ✗ /api/users/123/posts => マッチしません

ワイルドカードパターンのサポート

より柔軟なルーティングのために、ワイルドカードパターンをサポートする拡張版を実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
 * 拡張パスパターン変換
 * - :param - 単一セグメントにマッチ
 * - *wildcard - 残りのすべてのセグメントにマッチ
 */
function pathToRegexExtended(pattern) {
  const paramNames = [];
  let hasWildcard = false;

  let regexPattern = pattern
    // ワイルドカード(*name)を処理
    .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
      paramNames.push(name);
      hasWildcard = true;
      return '(.*)';
    })
    // 通常のパラメータ(:name)を処理
    .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
      paramNames.push(name);
      return '([^/]+)';
    });

  return {
    regex: new RegExp(`^${regexPattern}$`),
    paramNames,
    hasWildcard
  };
}

// テスト
const patterns = [
  '/api/users/:id',
  '/api/files/*filepath',
  '/api/:version/users/:id'
];

const testCases = [
  { pattern: '/api/files/*filepath', path: '/api/files/images/logo.png' },
  { pattern: '/api/:version/users/:id', path: '/api/v2/users/42' }
];

testCases.forEach(({ pattern, path }) => {
  const { regex, paramNames } = pathToRegexExtended(pattern);
  const match = path.match(regex);
  
  if (match) {
    const params = {};
    paramNames.forEach((name, i) => {
      params[name] = decodeURIComponent(match[i + 1]);
    });
    console.log(`パターン: ${pattern}`);
    console.log(`パス: ${path}`);
    console.log(`パラメータ:`, params);
    console.log('---');
  }
});
// 出力:
// パターン: /api/files/*filepath
// パス: /api/files/images/logo.png
// パラメータ: { filepath: 'images/logo.png' }
// ---
// パターン: /api/:version/users/:id
// パス: /api/v2/users/42
// パラメータ: { version: 'v2', id: '42' }

完成版ルーターの実装

機能を統合した完成版ルーター

これまでの実装を統合し、実用的なルーターを完成させます。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
const http = require('node:http');

class Router {
  constructor() {
    this.routes = [];
    this.middlewares = [];
  }

  /**
   * パスパターンを正規表現に変換する
   */
  pathToRegex(pattern) {
    const paramNames = [];
    
    const regexPattern = pattern
      .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
        paramNames.push(name);
        return '(.*)';
      })
      .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
        paramNames.push(name);
        return '([^/]+)';
      });

    return {
      regex: new RegExp(`^${regexPattern}$`),
      paramNames
    };
  }

  /**
   * ミドルウェアを追加する
   */
  use(middleware) {
    this.middlewares.push(middleware);
  }

  /**
   * ルートを追加する
   */
  addRoute(method, pattern, handler) {
    const { regex, paramNames } = this.pathToRegex(pattern);
    this.routes.push({
      method: method.toUpperCase(),
      pattern,
      regex,
      paramNames,
      handler
    });
    return this;
  }

  get(pattern, handler) { return this.addRoute('GET', pattern, handler); }
  post(pattern, handler) { return this.addRoute('POST', pattern, handler); }
  put(pattern, handler) { return this.addRoute('PUT', pattern, handler); }
  patch(pattern, handler) { return this.addRoute('PATCH', pattern, handler); }
  delete(pattern, handler) { return this.addRoute('DELETE', pattern, handler); }

  /**
   * ルートを検索する
   */
  findRoute(method, pathname) {
    for (const route of this.routes) {
      if (route.method !== method) continue;
      
      const match = pathname.match(route.regex);
      if (match) {
        const params = {};
        route.paramNames.forEach((name, index) => {
          params[name] = decodeURIComponent(match[index + 1]);
        });
        return { route, params };
      }
    }
    return null;
  }

  /**
   * ミドルウェアチェーンを実行する
   */
  async runMiddlewares(req, res, context) {
    for (const middleware of this.middlewares) {
      let nextCalled = false;
      await middleware(req, res, context, () => {
        nextCalled = true;
      });
      if (!nextCalled) return false;
    }
    return true;
  }

  /**
   * リクエストハンドラーを返す
   */
  handler() {
    return async (req, res) => {
      try {
        const baseUrl = `http://${req.headers.host}`;
        const url = new URL(req.url, baseUrl);
        
        const context = {
          url,
          params: {},
          query: url.searchParams,
          queryObject: Object.fromEntries(url.searchParams)
        };

        // ミドルウェアを実行
        const shouldContinue = await this.runMiddlewares(req, res, context);
        if (!shouldContinue) return;

        // ルートを検索
        const result = this.findRoute(req.method, url.pathname);
        
        if (result) {
          context.params = result.params;
          await result.route.handler(req, res, context);
        } else {
          this.sendNotFound(res, url.pathname);
        }
      } catch (error) {
        this.sendError(res, error);
      }
    };
  }

  sendNotFound(res, path) {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ 
      error: 'Not Found',
      message: `パス ${path} は見つかりませんでした`
    }));
  }

  sendError(res, error) {
    console.error('Server Error:', error);
    res.writeHead(500, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ 
      error: 'Internal Server Error',
      message: process.env.NODE_ENV === 'development' ? error.message : 'サーバーエラーが発生しました'
    }));
  }
}

// JSONボディパーサーミドルウェア
function jsonBodyParser() {
  return (req, res, context, next) => {
    return new Promise((resolve) => {
      if (req.method === 'GET' || req.method === 'DELETE') {
        next();
        resolve();
        return;
      }

      const contentType = req.headers['content-type'] || '';
      if (!contentType.includes('application/json')) {
        next();
        resolve();
        return;
      }

      let body = '';
      req.on('data', chunk => {
        body += chunk.toString();
      });
      req.on('end', () => {
        try {
          context.body = body ? JSON.parse(body) : {};
        } catch {
          context.body = {};
        }
        next();
        resolve();
      });
    });
  };
}

// ロギングミドルウェア
function requestLogger() {
  return (req, res, context, next) => {
    const start = Date.now();
    console.log(`→ ${req.method} ${context.url.pathname}`);
    
    res.on('finish', () => {
      const duration = Date.now() - start;
      console.log(`← ${req.method} ${context.url.pathname} ${res.statusCode} (${duration}ms)`);
    });
    
    next();
  };
}

// ===== 使用例 =====
const router = new Router();

// ミドルウェアを登録
router.use(requestLogger());
router.use(jsonBodyParser());

// ユーザーAPI
router.get('/api/users', (req, res, { queryObject }) => {
  const { page = 1, limit = 10 } = queryObject;
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    data: [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ],
    pagination: {
      page: Number(page),
      limit: Number(limit),
      total: 100
    }
  }));
});

router.get('/api/users/:id', (req, res, { params }) => {
  const userId = params.id;
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    id: userId,
    name: `User ${userId}`,
    email: `user${userId}@example.com`
  }));
});

router.post('/api/users', (req, res, { body }) => {
  res.writeHead(201, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    id: Date.now(),
    ...body,
    createdAt: new Date().toISOString()
  }));
});

router.put('/api/users/:id', (req, res, { params, body }) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    id: params.id,
    ...body,
    updatedAt: new Date().toISOString()
  }));
});

router.delete('/api/users/:id', (req, res) => {
  res.writeHead(204);
  res.end();
});

// ネストされたリソース
router.get('/api/users/:userId/posts', (req, res, { params, queryObject }) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    userId: params.userId,
    posts: [
      { id: 1, title: '最初の投稿' },
      { id: 2, title: '2番目の投稿' }
    ],
    query: queryObject
  }));
});

router.get('/api/users/:userId/posts/:postId', (req, res, { params }) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    userId: params.userId,
    postId: params.postId,
    title: `投稿 ${params.postId}`,
    content: 'これは投稿の内容です。'
  }));
});

// ヘルスチェック
router.get('/health', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() }));
});

const server = http.createServer(router.handler());
server.listen(3000, () => {
  console.log('サーバーが http://localhost:3000 で起動しました');
  console.log('利用可能なエンドポイント:');
  console.log('  GET    /api/users');
  console.log('  GET    /api/users/:id');
  console.log('  POST   /api/users');
  console.log('  PUT    /api/users/:id');
  console.log('  DELETE /api/users/:id');
  console.log('  GET    /api/users/:userId/posts');
  console.log('  GET    /api/users/:userId/posts/:postId');
  console.log('  GET    /health');
});

動作確認

curlコマンドを使用してAPIをテストします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ユーザー一覧取得(ページネーション付き)
curl "http://localhost:3000/api/users?page=2&limit=20"

# 特定ユーザー取得
curl http://localhost:3000/api/users/42

# ユーザー作成
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Charlie", "email": "charlie@example.com"}'

# ユーザー更新
curl -X PUT http://localhost:3000/api/users/42 \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated Name"}'

# ユーザー削除
curl -X DELETE http://localhost:3000/api/users/42 -v

# ネストされたリソース
curl "http://localhost:3000/api/users/42/posts?sort=createdAt"
curl http://localhost:3000/api/users/42/posts/1

# ヘルスチェック
curl http://localhost:3000/health

ルーティング処理の流れ

実装したルーターの処理フローを図で確認しましょう。

flowchart TD
    A[HTTPリクエスト受信] --> B[URLをパース]
    B --> C[ミドルウェアチェーン実行]
    C --> D{ミドルウェア完了?}
    D -->|No| E[レスポンス送信済み]
    D -->|Yes| F[ルート検索]
    F --> G{マッチするルート?}
    G -->|Yes| H[パスパラメータ抽出]
    H --> I[ハンドラー実行]
    I --> J[レスポンス送信]
    G -->|No| K[404 Not Found]
    
    subgraph "URL解析"
        B
    end
    
    subgraph "ルーティング"
        F
        G
        H
    end

まとめ

この記事では、Node.jsの組み込みAPIを使用したHTTPルーティングの実装方法を解説しました。

学んだ内容を振り返りましょう。

  • URLクラス: WHATWG URL Standardに準拠したURL解析APIで、pathnamesearchsearchParamsなどのプロパティを使用してURLの各部分にアクセスできます

  • URLSearchParams: クエリパラメータの取得(get/getAll)、追加(append/set)、削除(delete)、イテレーションなどの操作が可能です

  • パスベースルーティング: HTTPメソッドとパスの組み合わせでハンドラーを振り分けるシンプルなルーターを実装しました

  • パスパラメータ抽出: 正規表現を使用して/users/:id形式の動的パラメータを抽出する方法を学びました

  • ミドルウェアパターン: リクエスト処理の前後に共通処理を挿入するミドルウェアパターンを実装しました

これらの知識は、ExpressやFastifyなどのフレームワークの内部動作を理解する基盤となります。フレームワークを使用する場合でも、その仕組みを理解していることで、より効果的なデバッグやカスタマイズが可能になります。

参考リンク