【API待ち解消】「モックで進めました(圧)」で進捗報告が強気に!Express.jsで始めるリアルガチ・モックサーバー実装術(HTTPS/プロキシ対応)
はじめに
こんにちは、株式会社メンバーズ Cross Application カンパニーの田原です。
iOS でも、Android でも、Webフロントでも、アプリ開発をしていれば幾度もぶち当たるのがこのセリフ。
あ、そのAPI、まだできてないんですよね〜
設計は終わっている。UIも実装済み。だけど、肝心のAPIが、「未実装」「未定義」「Coming Soon」。
この記事は、進捗が止まりそうなときに、「止まらない選択肢」を持つためのガイドです。
開発を止めないためのアプローチ:3つのレベル
進捗を出し続けるためのモックサーバーの開発。どこまでやるか?
一口にモックサーバーと言っても、その導入の手間や技術的な難易度、そして「どこまで作り込むか」は悩ましいポイントです。ここでは、モックサーバーを活用した開発アプローチを、主に手間と技術的な観点から三段階にレベル分けし、それぞれの概要と特徴をご紹介します。
本記事では、これらの三段階のレベルそれぞれについて、具体的な設定方法や実装手順を順を追って詳しく解説していきます。
LEVEL-1: シンプルなローカルモックサーバー
概要:
開発PC(localhost)上に自分でモックサーバーを立ち上げ、モック化したい特定のエンドポイントのAPI応答を実装します。
アプリケーションからの利用:
アプリケーション側で、APIリクエストの接続先情報を、起動したモックサーバーのアドレス(例: http://localhost:3000 など)に一時的に書き換えることで、モックサーバーを利用します。
注意点:
この方法では、モックサーバーに実装されていないAPIエンドポイントへのリクエストは処理できません。そのため、アプリケーションが期待するレスポンスを返せず、エラーになる可能性があります。アプリケーション全体でベースURLを書き換える場合は、利用するAPIが広範囲に及ぶ場合、対応するモック実装の範囲も広くなる点に注意が必要です。
LEVEL-2: プロキシを利用した透過的モックサーバー
概要:
LEVEL-1 と同様に localhost 上にモックサーバーを構築しますが、これに加えて http-proxy-middleware のようなプロキシツールを導入します。
仕組みと利点:
モックサーバーで実装した特定のエンドポイントへのリクエストはモックデータが返され、それ以外のエンドポイントへのリクエストは、プロキシを通じて自動的に本来のAPIサーバーへ転送されます。これにより、アプリケーション側は API の接続先をモックサーバーのアドレスに向けるだけで済み、API ごとに設定を細かく変更するのが難しい場合や、一部のAPIだけを選択的にモック化したい場合に非常に有効です。
LEVEL-3: hostsファイルを利用したリクエストの振り分け
概要:
開発PCの hosts ファイルを編集します。このファイルに、本来のAPIサーバーのドメイン名と 127.0.0.1(localhost)を紐付ける設定を追記します。
仕組みと利点:
これにより、アプリケーションのコード(API の接続先設定など)を一切変更することなく、そのドメイン名へのリクエストが、自動的にローカルで起動しているモックサーバーに向けられるようになります。モックサーバー側では、受け取ったリクエストに応じてモックデータを返すか、あるいは LEVEL-2 のプロキシ設定と組み合わせて、モック化しないリクエストは本来のサーバーへ転送するといった処理を実装します。
注意点:
hosts ファイルの編集には管理者権限が必要です。また、この設定はPC全体のネットワークリクエストに影響を与えるため、テスト終了後は必ず設定を元に戻すのを忘れないように注意が必要です。
LEVEL-1: シンプルなローカルモックサーバー
モックサーバーを立てる方法はいくつかありますが、ここでは、私の技術スタックの都合上、平易で柔軟な方法として、Node.js と Express を使います。
フロントエンドやモバイルアプリの開発中に、「とりあえずこのエンドポイントだけでも先に欲しい」という状況にも迅速に対応できるため、この方法を一つ覚えておくと便利です。
プロジェクトの初期化と Express のインストール
まずは、新しいディレクトリを作成し、Node.js のプロジェクトを初期化します。ここでは、ディレクトリ名を仮に uncoming-api とします(圧)
mkdir uncoming-api
cd uncoming-api
npm init -y
次に Express をインストールします。
npm install express
Express による API 作成(現行APIの再現)
ここでは例として、ユーザー情報を取得・作成・更新・削除する /users エンドポイントについて改修が必要になった状況を想定します。
モックサーバーの実装方針として、以下の手順で進めます。
- まず、現在稼働している(または確定している)APIの仕様でモックサーバーを構築し、既存のアプリケーションがそのモックに対して正しく動作することを確認します。
- その上で、新しい改修仕様に基づいてモックAPIを実装(または修正)し、アプリケーション側もその新しい仕様に対応させて動作確認を行う。
今回はまず、最初のステップである「現状のAPI仕様に基づいてシンプルなモックAPIを作成し、現在のアプリがそれに対して正しく動作するかを確認する手順」を見ていきましょう。
JSON データの用意
現状の取得APIが実際に返しているレスポンスを元に、JSONファイルを用意します。もしAPIがまだ存在しない場合は、確定しているAPIのレスポンス仕様に基づいて作成します。レスポンスが配列の場合、その要素の一部を記述するだけでも、初期の動作確認には十分な場合があります。
例として、以下のようなJSONデータを用意します。
{
"users": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com"
},
{
"id": 2,
"name": "Jane Smith",
"email": "jane.smith@example.com"
},
{
"id": 3,
"name": "Alice Johnson",
"email": "alice.johnson@example.com"
}
]
}
JSONファイルが用意できたら、resource という名前のフォルダを作成し、その中に配置します。 全体のファイル構成は以下のようになります。
uncoming-api/
├── resource/
│ └── users.json
├── routes/
│ └── users.js // このファイルについては後ほど説明します
└── server.js // このメインファイルも後ほど作成します
モックサーバーの実装
ここからは、実際にモックサーバーを実装していきます。 基本的には、Express を使ったシンプルなサーバーアプリケーションとなります。
このモックサーバーで最も重要なポイントは、用意したJSONファイルを直接読み書きすることで、データの参照(Read)はもちろん、作成(Create)、更新(Update)、削除(Delete)といった、いわゆるCRUD操作を擬似的に表現するという点です。これにより、バックエンドのAPIが完全に動作していなくても、フロントエンドやアプリ側は様々なデータ操作のテストを進められるようになります。
以下に server.js と users.js の実装を示します。これを雛形にすることで、様々なAPIのモック実装が可能です。実装の詳細は「Express による API 作成(改修APIの実装)」のセクションで解説します。
server.js
/**
* このファイルは、Express.js を使用してAPIモックサーバーを起動するためのメインスクリプトです。
* 以下の主な処理を行います:
* 1. 必要なモジュールの読み込み (express, path など)
* 2. Expressアプリケーションインスタンスの作成
* 3. 共通ミドルウェアの設定 (例: JSONパーサー)
* 4. 外部ファイルに定義されたAPIルートの読み込みとアプリケーションへのマウント
* 5. 指定されたポートでのHTTPサーバーの起動とリクエストの待ち受け開始
*/
// Expressフレームワークを読み込みます。Node.jsでWebアプリケーションを構築するための主要なツールです。
const express = require('express');
// Node.js標準の 'path' モジュールを読み込みます。ファイルパスの操作や解決に便利です。
const path = require('path');
// Expressアプリケーションのインスタンスを作成します。これがサーバー全体の基本となります。
const app = express();
// サーバーがリッスンするポート番号を定義します。
const port = 3000;
// --- ミドルウェアの設定 ---
// Expressアプリケーションが受け取ったリクエストのボディがJSON形式である場合、
// それをJavaScriptオブジェクトとして解析(パース)するためのミドルウェアを適用します。
// これにより、POSTやPATCHリクエストなどで送信されたJSONデータに req.body でアクセスできるようになります。
app.use(express.json());
// --- ルーターの読み込みとマウント ---
// './routes/users.js' ファイルに定義されたユーザー関連のAPIルートを読み込みます。
// 'require' を使うことで、別のファイルに分割されたモジュール(この場合はExpressのルーター)を利用できます。
const userRoutes = require('./routes/users');
// 読み込んだユーザー関連のルート (userRoutes) を、'/users' というベースパスにマウント(割り当て)します。
// これにより、例えば userRoutes で定義された '/' へのルートは、実際には '/users' としてアクセス可能になり、
// '/:id' へのルートは '/users/:id' としてアクセス可能になります。
// このようにして、APIのエンドポイントをモジュール化し、整理することができます。
app.use('/users', userRoutes);
// (もし他のリソース、例えば '/products' に関するルートがあれば、同様に追加できます)
// const productRoutes = require('./routes/products');
// app.use('/products', productRoutes);
// --- サーバーの起動 ---
// 指定されたポート (port) でHTTPリクエストの待ち受けを開始します。
// サーバーが起動すると、第2引数に渡されたコールバック関数が実行されます。
app.listen(port, () => {
// サーバーが正常に起動したことを示すメッセージをコンソールに出力します。
console.log(`Mock API server is running at http://localhost:${port}`);
// ユーザーAPIがどのベースURLで利用可能かを示します。
console.log(`User API endpoints are available under http://localhost:${port}/users`);
// ユーザーデータが保存されているJSONファイルの場所を示します。
// path.joinを使って、現在のディレクトリ (__dirname) からの相対パスで安全にファイルパスを構築しています。
console.log(`Data is stored in: ${path.join(__dirname, 'resource', 'users.json')}`);
});
users.js
// routes/users.js
/**
* このファイルは、ユーザーリソース (/users) に関連するAPIのルーティングを定義します。
* ExpressのRouter機能を利用して、各HTTPメソッド(GET, POST, PATCH, DELETE)に対応する
* エンドポイントと、それぞれの処理内容を記述しています。
* データはプロジェクト内の 'resource/users.json' ファイルにJSON形式で保存され、
* このファイルを通じて読み書きが行われます。
*/
const express = require('express');
const router = express.Router(); // ExpressのRouterインスタンスを作成
const fs = require('fs'); // Node.js標準のファイルシステムモジュール
const path = require('path'); // Node.js標準のパス操作モジュール
// ユーザーデータが保存されているJSONファイルへの絶対パスを構築します。
// '__dirname' は現在のファイル (users.js) が存在するディレクトリ (routes/) を指すため、
// '../resource/users.json' でプロジェクトルート下の 'resource/users.json' を指定します。
const usersFilePath = path.join(__dirname, '..', 'resource', 'users.json');
// --- データアクセス用ヘルパー関数 ---
// 以下のヘルパー関数は、users.jsonファイルの読み書きやID生成といった共通処理を担います。
/**
* 'users.json' ファイルからユーザーデータを同期的に読み込み、JavaScriptオブジェクトとして返します。
* ファイルが存在しない場合やJSONとして不正な場合は、空のユーザーリストを持つオブジェクトを返します。
* @returns {{ users: Array<Object> }} ユーザーデータ。例: { users: [{ id: 1, name: "John" }] }
*/
function readUsersData() {
try {
// ファイルの内容をUTF-8文字列として読み込みます。
const data = fs.readFileSync(usersFilePath, 'utf8');
// 読み込んだ文字列をJSONオブジェクトにパースします。
return JSON.parse(data);
} catch (error) {
// ファイル読み込みやJSONパース中にエラーが発生した場合のフォールバック処理です。
console.error(`[${new Date().toISOString()}] Error reading users data from ${usersFilePath}: ${error.message}`);
// ファイルが存在しない (ENOENT) か、内容が空または不正なJSON (SyntaxError) の場合、
// アプリケーションがエラーで停止しないよう、初期状態のデータ構造を返します。
if (error.code === 'ENOENT' || error instanceof SyntaxError) {
return { users: [] };
}
// その他の予期せぬエラーの場合は、エラーを再スローして上位の処理に任せます。
throw error;
}
}
/**
* 与えられたJavaScriptオブジェクトを整形されたJSON文字列に変換し、'users.json'ファイルに同期的に書き込みます。
* @param {Object} data 書き込むデータ。通常は { users: [...] } という形式。
*/
function writeUsersData(data) {
try {
// JavaScriptオブジェクトをJSON文字列に変換します。
// 'null, 2' はJSONを人間が読みやすいように2スペースでインデントして整形するためのオプションです。
const jsonData = JSON.stringify(data, null, 4);
// 整形されたJSON文字列をファイルに書き込みます。
fs.writeFileSync(usersFilePath, jsonData, 'utf8');
} catch (error) {
// ファイル書き込み中にエラーが発生した場合の処理です。
console.error(`[${new Date().toISOString()}] Error writing users data to ${usersFilePath}: ${error.message}`);
throw error;
}
}
/**
* 新しいユーザーのためのユニークなIDを生成します (簡易的な実装)。
* 現在のユーザーリスト内で最も大きなIDを探し、それに1を加えた値を返します。
* ユーザーが一人もいない場合は、1から開始します。
* @param {{ users: Array<{id: number}> }} usersData 現在の全ユーザーデータ。
* @returns {number} 次に使用可能な新しいユーザーID。
*/
function getNextUserId(usersData) {
// usersプロパティが存在しない、またはユーザーリストが空の場合、最初のIDとして1を返します。
if (!usersData.users || usersData.users.length === 0) {
return 1;
}
// 既存ユーザーのIDの中から最大値を見つけます。
// reduceメソッドを使って、各ユーザーのidと現在の最大値を比較し、より大きい方を採用します。
// 初期値は0としています。
const maxId = usersData.users.reduce((max, user) => (user.id > max ? user.id : max), 0);
// 最大IDに1を加えて、新しいユニークなIDとします。
return maxId + 1;
}
// --- APIエンドポイントの定義 ---
// ここから、各HTTPリクエスト(GET, POST, PATCH, DELETE)に対応する
//具体的なAPI処理を定義していきます。
// router.get('/', ...) の '/' は、メインファイル(server.jsなど)で
// このルーターが '/users' にマウントされているため、実質的に '/users' を意味します。
/**
* GET /users
* 登録されている全ユーザーのリストを取得します。
*/
router.get('/', (req, res) => {
console.log(`[${new Date().toISOString()}] GET /users (handled by routes/users.js)`);
try {
// 1. ファイルから全ユーザーデータを読み込みます。
const usersData = readUsersData();
// 2. ユーザーの配列 (usersData.users) をJSON形式でクライアントに返します。
// もしusersData.usersが存在しない場合(例:ファイルが空だった直後など)は空配列を返します。
res.json(usersData.users || []);
} catch (error) {
// 3. データ読み込み中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in GET /users: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ユーザーデータの取得に失敗しました。' });
}
});
/**
* GET /users/:id
* 指定されたIDを持つ特定のユーザーの情報を取得します。
* :id 部分はURLパラメータとして扱われ、リクエストされたユーザーIDが入ります。
*/
router.get('/:id', (req, res) => {
const requestedId = req.params.id; // URLから:id部分の値を取得
console.log(`[${new Date().toISOString()}] GET /users/${requestedId} (handled by routes/users.js)`);
try {
// 1. ファイルから全ユーザーデータを読み込みます。
const usersData = readUsersData();
// 2. URLパラメータで受け取ったIDを数値に変換します (URLパラメータは文字列として渡されるため)。
const userId = parseInt(requestedId, 10);
// 3. ユーザーリストから、指定されたIDに一致するユーザーを探します。
const foundUser = usersData.users.find(user => user.id === userId);
if (foundUser) {
// 4a. ユーザーが見つかった場合、そのユーザーの情報をJSONで返します。
res.json(foundUser);
} else {
// 4b. ユーザーが見つからなかった場合、404 Not Foundエラーを返します。
res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
} catch (error) {
// 5. データ読み込み中や処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in GET /users/${requestedId}: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ユーザー情報の取得に失敗しました。' });
}
});
/**
* POST /users
* 新しいユーザーを作成します。ユーザー情報はリクエストボディにJSON形式で含めます。
* 例: { "name": "Taro Yamada", "email": "taro@example.com" }
*/
router.post('/', (req, res) => {
console.log(`[${new Date().toISOString()}] POST /users (handled by routes/users.js) with body:`, req.body);
try {
// 1. リクエストボディから新しいユーザーの名前とメールアドレスを取得します。
const { name, email } = req.body;
// 2. 入力値の簡易的なバリデーション: nameとemailが提供されているかを確認します。
if (!name || !email) {
return res.status(400).json({ message: '名前とメールアドレスは必須です。' }); // 400 Bad Request
}
// 3. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
// 4. (簡易バリデーション) 提供されたメールアドレスが既に存在するかチェックします。
if (usersData.users.some(user => user.email === email)) {
// 既に存在する場合は、409 Conflictエラーを返します。
return res.status(409).json({ message: `メールアドレス '${email}' は既に使用されています。` });
}
// 5. 新しいユーザーオブジェクトを作成します。IDは自動生成します。
const newUser = {
id: getNextUserId(usersData),
name: name,
email: email
};
// 6. 新しいユーザーをユーザーリストに追加します。
usersData.users.push(newUser);
// 7. 更新されたユーザーリスト全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
// 8. ユーザー作成成功を示すステータスコード201 (Created) と共に、
// 作成されたユーザーの情報をJSONでクライアントに返します。
res.status(201).json(newUser);
} catch (error) {
// 9. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in POST /users: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ユーザーの作成に失敗しました。' });
}
});
/**
* PATCH /users/:id
* 指定されたIDを持つ特定のユーザーの情報を部分的に更新します。
* 更新する情報はリクエストボディにJSON形式で含めます (例: { "name": "New Name" })。
* ボディに含まれるフィールドのみが更新対象となります。
*/
router.patch('/:id', (req, res) => {
const requestedId = req.params.id;
console.log(`[${new Date().toISOString()}] PATCH /users/${requestedId} (handled by routes/users.js) with body:`, req.body);
try {
// 1. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
// 2. URLパラメータからユーザーIDを取得し、数値に変換します。
const userId = parseInt(requestedId, 10);
// 3. 更新対象のユーザーがリスト内に存在するか、そのインデックスを探します。
const userIndex = usersData.users.findIndex(user => user.id === userId);
// 4. ユーザーが見つからない場合は404 Not Foundエラーを返します。
if (userIndex === -1) {
return res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
// 5. 更新対象のユーザーオブジェクトへの参照を取得します。
const userToUpdate = usersData.users[userIndex];
// 6. リクエストボディに含まれている情報でユーザーデータを更新します。
// 'name' がリクエストボディにあれば、名前を更新します。
if (req.body.name !== undefined) {
userToUpdate.name = req.body.name;
}
// 'email' がリクエストボディにあれば、メールアドレスを更新します。
if (req.body.email !== undefined) {
// (簡易バリデーション) 更新しようとしているメールアドレスが、
// 他のユーザー(自分自身を除く)に既に使用されていないかチェックします。
if (usersData.users.some(user => user.email === req.body.email && user.id !== userId)) {
return res.status(409).json({ message: `メールアドレス '${req.body.email}' は他のユーザーに既に使用されています。` });
}
userToUpdate.email = req.body.email;
}
// 他に更新可能なフィールドがあれば、同様のロジックを追加できます。
// 7. 更新されたユーザー情報で、元の配列内の情報を置き換えます。
usersData.users[userIndex] = userToUpdate;
// 8. 更新されたユーザーリスト全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
// 9. 更新後のユーザー情報をJSONでクライアントに返します。
res.json(userToUpdate);
} catch (error) {
// 10. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in PATCH /users/${requestedId}: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ユーザーの更新に失敗しました。' });
}
});
/**
* DELETE /users/:id
* 指定されたIDを持つ特定のユーザーを削除します。
*/
router.delete('/:id', (req, res) => {
const requestedId = req.params.id;
console.log(`[${new Date().toISOString()}] DELETE /users/${requestedId} (handled by routes/users.js)`);
try {
// 1. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
// 2. URLパラメータからユーザーIDを取得し、数値に変換します。
const userId = parseInt(requestedId, 10);
// 3. 削除前のユーザー数を記録しておきます(ユーザーが見つかったかどうかの判定用)。
const initialUserCount = usersData.users.length;
// 4. 指定されたID以外のユーザーで新しい配列を再構築し、実質的に対象ユーザーを削除します。
usersData.users = usersData.users.filter(user => user.id !== userId);
// 5. ユーザーリストの長さが変わっていなければ、対象のユーザーが見つからなかった(削除されなかった)と判断します。
if (usersData.users.length === initialUserCount) {
return res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
// 6. 更新されたユーザーリスト全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
// 7. 削除成功を示すステータスコード204 (No Content) を返します。
// このステータスコードは、レスポンスボディがないことを意味します。
res.status(204).send();
} catch (error) {
// 8. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in DELETE /users/${requestedId}: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ユーザーの削除に失敗しました。' });
}
});
// このルーターモジュールをエクスポートし、メインのサーバーファイル (server.js) から利用できるようにします。
module.exports = router;
動作の検証
それでは、作成したモックサーバーが意図した通りに動作するか、実際に確認してみましょう。
まず、ターミナルを開き、プロジェクトのルートディレクトリ(uncoming-api フォルダの直下)で以下のコマンドを実行して、モックサーバーを起動します。
node server.js
コマンドが正常に実行されると、コンソールには次のようなメッセージが表示されます。これが表示されれば、サーバーは無事起動しています。(表示されるパス Data is stored in: の部分は、ご自身の環境によって異なります。)
myuser@mypc uncoming-api % node server.js
Mock API server is running at http://localhost:3000
User API endpoints are available under http://localhost:3000/users
Data is stored in: /Users/myuser/uncoming-api/resource/users.json
サーバーが起動したら、動作確認をしてみましょう。 一番簡単な方法は、Webブラウザのアドレスバーに http://localhost:3000/users と入力してアクセスすることです。これにより、resource/users.json に記述したユーザー情報の一覧(モックデータ)がブラウザに表示されるはずです。 または、ターミナルから curl http://localhost:3000/users のようなコマンドを実行しても同様にデータを確認できます。
実際のアプリケーション(iOSアプリ、Androidアプリ、フロントエンドアプリケーションなど)からこのモックサーバーにリクエストを送りたい場合は、アプリケーション側で設定されているAPIのベースURL(APIのエンドポイントの基点となるURL)を http://localhost:3000 に変更する必要があります。こうすることで、アプリケーションは実際のバックエンドサーバーではなく、ローカルで起動しているこのモックサーバーに対してデータをリクエストするようになります。
Express による API 作成(改修APIの実装)
前のステップで、現状のAPI仕様に基づいたモックサーバーの基本的な動作確認ができました。 次は、いよいよアプリケーションの改修で必要となる新しいAPI仕様を、このモックサーバーで再現していく手順を見ていきます。
改修仕様(例)
ここでは、以下のような仕様変更が発生したと仮定して進めます。
- ユーザー情報に notes プロパティが追加される:
- 各ユーザー情報オブジェクトの中に、そのユーザーに関連する複数のメモを保持するための notes というプロパティ(配列形式)が新たに追加されます。各メモオブジェクトには、固有の id、メモ内容を示す content、作成日時 created_at が含まれるようになります。
- 既存のユーザー取得APIのレスポンスが変更される:
- GET /users や GET /users/:id といった、ユーザー情報を取得するAPIのレスポンスにも、上記1の notes が含まれるように変更されます。
- 新規ユーザー作成時に初期 notes を設定:
- POST /users で新しいユーザーが作成される際、そのユーザーの初期データとして、自動的に空の notes: [] プロパティが付与されるようになります。
- Notes を操作するための新しいエンドポイントが追加される:
- 特定のユーザーのメモを作成・更新・削除するために、以下のような形式で新しいAPIエンドポイントが追加されます。
- POST /users/:userId/notes : 特定のユーザーに新しいメモを作成
- PATCH /users/:userId/notes/:noteId : 特定のユーザーの特定のメモを更新
- DELETE /users/:userId/notes/:noteId : 特定のユーザーの特定のメモを削除 ( :userId は対象ユーザーのID、:noteId は対象メモのIDを指します。)
- 特定のユーザーのメモを作成・更新・削除するために、以下のような形式で新しいAPIエンドポイントが追加されます。
JSON の更新
まずはじめに、モックサーバーが参照するデータソースである resource/users.json ファイルの内容を、前述した新しいAPI仕様に合わせて更新します。
具体的には、各ユーザーオブジェクトに notes というキー名で配列プロパティを追加します。この notes 配列には、そのユーザーに関連するメモ情報(各メモは id, content, created_at を持つオブジェクト)を格納します。まだメモがないユーザーや、新規作成時に初期化される場合は、空の配列 [] を設定します。
{
"users": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"notes": [
{
"id": 1,
"content": "This is a note for John Doe.",
"created_at": "2023-10-03T12:00:00Z"
},
{
"id": 2,
"content": "Another note for John Doe.",
"created_at": "2023-10-02T11:00:00Z"
}
]
},
{
"id": 2,
"name": "Jane Smith",
"email": "jane.smith@example.com",
"notes": [
{
"id": 1,
"content": "This is a note for Jane Smith.",
"created_at": "2023-10-03T12:00:00Z"
}
]
},
{
"id": 3,
"name": "Alice Johnson",
"email": "alice.johnson@example.com",
"notes": []
}
]
}
POST /users の修正
routes/users.js ファイルの具体的な改修作業に入ります。
最初のステップとして、「改修仕様(例)」で定義した仕様「3. 新規ユーザー作成時に初期 notes を設定」を実装します。
これは、新しいユーザーが POST /users エンドポイントを通じて作成される際に、そのユーザーオブジェクトに { notes: [] } という空の配列をあらかじめ含めておく、というものです。
まずは、修正対象となる routes/users.js 内の POST /users エンドポイントにおける、ユーザーオブジェクトを生成している部分の現在のコード(修正前)を確認します。それでは、routes/users.js ファイルの具体的な改修作業に入ります。
// routes/users.js の router.post('/', ...) 内
// ...
// 5. 新しいユーザーオブジェクトを作成します。IDは自動生成します。
const newUser = {
id: getNextUserId(usersData),
name: name,
email: email
};
// ...
ご覧の通り、この段階では newUser オブジェクトに notes プロパティはまだ含まれていません。
この部分を修正し、新しいユーザーが作成されると同時に notes プロパティが空の配列として初期化されるように変更します。
// 5. 新しいユーザーオブジェクトを作成します。IDは自動生成します。
const newUser = {
id: getNextUserId(usersData),
name: name,
email: email,
notes: [] // ← この行で notes プロパティを空の配列として追加
};
この一行を追加するだけで、POST /users で新しいユーザーが作成される際に、レスポンスとして返されるユーザーオブジェクト、そして users.json に保存されるユーザーデータにも notes: [] が含まれるようになります。
POST /users/:userId/notes の実装
次に、「POST /users/:userId/notes : 特定のユーザーに新しいメモを作成」を作っていきます。基本は resource/users.js に実装したものを雛形として、適宜書き換えていきます。
まずは、完成形となるコードを確認します。
完成形コード(POST /users/:userId/notes)
/**
* POST /users/:userId/notes
* 指定されたユーザーIDに対して新しいノートを追加します。
* ノートの内容はリクエストボディにJSON形式で含めます。
* 例: { "content": "This is a new note." }
*/
router.post('/:userId/notes', (req, res) => {
console.log(`[${new Date().toISOString()}] POST /users/:userId/notes (handled by routes/users.js) with body:`, req.body);
try {
// 1. リクエストパラメータからユーザーIDを取得します。
const userId = parseInt(req.params.userId, 10); // URLパラメータからユーザーIDを取得し、数値に変換します。
// 2. リクエストボディからノートの内容を取得します。
const { content } = req.body; // リクエストボディからノートの内容を取得します。
// 3. 入力値の簡易的なバリデーション: contentが提供されているかを確認します。
if (!content) {
return res.status(400).json({ message: 'ノートの内容は必須です。' }); // 400 Bad Request
}
// 4. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
// 5. 指定されたユーザーIDに一致するユーザーを探します。
const user = usersData.users.find(user => user.id === userId);
// 6. ユーザーが見つからない場合は404 Not Foundエラーを返します。
if (!user) {
return res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
// 7. 新しいノートオブジェクトを作成します。IDは自動生成します。
const newNote = {
id: getNextNoteId(user), // ノートIDの自動生成
content: content,
createdAt: new Date().toISOString() // 作成日時をISO形式で保存
};
// 8. ユーザーのノートリストに新しいノートを追加します。
user.notes.push(newNote);
// 9. 更新されたユーザーデータ全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
// 10. ノート作成成功を示すステータスコード201 (Created) と共に、
// 作成されたノートの情報をJSONでクライアントに返します。
res.status(201).json(newNote);
} catch (error) {
// 11. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in POST /users/:userId/notes: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ノートの作成に失敗しました。' });
}
});
// --- データアクセス用ヘルパー関数 ---
/**
* 特定のユーザーの新しいノートのためのユニークなIDを生成します。
* そのユーザーの既存のノートIDの最大値に1を加えた値を返します。
* ノートが一つも存在しない場合は、1から開始します。
* @param {Object} user - 対象のユーザーオブジェクト (user.notes を持つことを期待)
* @returns {number} 次に使用可能な新しいノートID。
*/
function getNextNoteId(user) {
// user.notes が存在し、かつ配列であることを確認します。
if (!user || !Array.isArray(user.notes) || user.notes.length === 0) {
return 1; // ノートがない、またはnotesプロパティがない場合、最初のIDとして1を返します。
}
// ノートのIDの中から最大値を見つけます。
const maxId = user.notes.reduce((max, note) => (note.id > max ? note.id : max), 0);
// 最大IDに1を加えて、新しいユニークなノートIDとします。
return maxId + 1;
}
完成形コード(getNextNoteId(user))
POST /users でユーザーIDを生成する際に getNextUserId(usersData) というヘルパー関数を使ったように、今回もノートIDを生成するための新しいヘルパー関数 getNextNoteId(user) を導入します。
この関数は、引数として特定のユーザーオブジェクト user を受け取り、そのユーザーの notes 配列内で次に使用すべきユニークなID番号を計算して返します。具体的な実装は routes/users.js のヘルパー関数群のセクションに以下のように追加することを想定します。
// routes/users.js (ヘルパー関数セクションに追加)
/**
特定のユーザーの新しいノートのためのユニークなIDを生成します。
そのユーザーの既存のノートIDの最大値に1を加えた値を返します。
ノートが一つも存在しない場合は、1から開始します。
@param {Object} user - 対象のユーザーオブジェクト (user.notes を持つことを期待)
@returns {number} 次に使用可能な新しいノートID。
/
function getNextNoteId(user) {
// user.notes が存在し、かつ配列であることを確認します。
if (!user || !Array.isArray(user.notes) || user.notes.length === 0) {
console.log(`[${new Date().toISOString()}] No notes found for user, or notes array is empty. Returning ID 1.`);
// ユーザーやノートのデータ構造を確認するために、詳細なログを出力するのはデバッグに有効です。
// console.log(`[${new Date().toISOString()}] User data (for getNextNoteId):`, JSON.stringify(user, null, 2));
return 1; // ノートがない、またはnotesプロパティがない場合、最初のIDとして1を返します。
}
// ノートのIDの中から最大値を見つけます。
const maxId = user.notes.reduce((currentMax, note) => {
const noteIdAsNumber = parseInt(note.id, 10); // note.id を10進数の数値に変換
// noteIdAsNumber が有効な数値であり、かつ現在の最大値 (currentMax) より大きいかを確認
if (!isNaN(noteIdAsNumber) && noteIdAsNumber > currentMax) {
return noteIdAsNumber; // 新しい最大値を返す
}
return currentMax; // そうでなければ、現在の最大値を維持
}, 0); // 初期値を0とする
// 最大IDに1を加えて、新しいユニークなノートIDとします。
return maxId + 1;
}
実装手順
完成形のコードの各部分がどのようにして生まれたのか、実装手順を見ていきます。
1. エンドポイントの定義
まず、新しいエンドポイントの基本的な形を routes/users.js に定義します。 POST /users と同様に、router.post() を使いますが、今回はパスが /:userId/notes となります。これは、特定のユーザー (:userId) に紐づくノートを作成するという意味です。
// routes/users.js
// ... (既存のGET, POST /users などのエンドポイント定義) ...
router.post('/:userId/notes', (req, res) => {
console.log(`[${new Date().toISOString()}] POST /users/${req.params.userId}/notes (handled by routes/users.js) with body:`, req.body);
try {
// --- ここから処理を記述していく ---
// (仮のレスポンス)
res.send('POST /users/:userId/notes endpoint hit!');
} catch (error) {
console.error(`[${new Date().toISOString()}] Error in POST /users/:userId/notes: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ノートの作成に失敗しました。' });
}
});
この try...catch 構造は、POST /users と全く同じ考え方で、予期せぬエラーが発生した場合にサーバーがクラッシュせず、適切にエラーレスポンスを返せるようにするためのものです。
2. リクエスト情報の取得
次に、リクエストから必要な情報を取り出します。 POST /users では、リクエストボディから name と email を取得しました。今回は、どのユーザーに対するメモなのかを示す userId と、メモの内容である content が必要です。 userId はURLのパスパラメータ(例: /users/1/notes の 1 の部分)から、content はリクエストボディから取得します。
// routes/users.js の POST /users/:userId/notes 内
// 1. リクエストパラメータからユーザーIDを取得します。
const userId = parseInt(req.params.userId, 10); // :userId を数値として取得
// 2. リクエストボディからノートの内容を取得します。
const { content } = req.body; // ボディから content を取得
req.params.userId でURLからIDを取得し、parseInt() で数値に変換するのは、GET /users/:id と同じアプローチです。content の取得は POST /users と同様に req.body からです。
3. 入力値のバリデーション
POST /users では、name と email が空でないかをチェックしました。同様に、POST /users/:userId/notes でも、メモの内容 content がちゃんと送られてきているかを確認します。
// routes/users.js の POST /users/:userId/notes 内
// 3. 入力値の簡易的なバリデーション: contentが提供されているかを確認します。
if (!content) {
return res.status(400).json({ message: 'ノートの内容は必須です。' });
}
ここでも、もしバリデーションエラーがあれば 400 Bad Request を返す、という考え方は POST /users と共通です。
4. ユーザーデータの読み込み
リクエストから必要な情報を取得し、バリデーションも完了したら、次に実際のデータ操作の準備として、保存されている全ユーザーデータをファイルから読み込みます。 これは POST /users で新しいユーザーを追加する前に、既存のユーザー情報を読み込んだのと同じ手順です。
// routes/users.js の POST /users/:userId/notes 内
// 4. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
ヘルパー関数 readUsersData() を呼び出すだけで、users.json の内容がJavaScriptオブジェクトとして取得できます。
5. 対象ユーザーの特定
POST /users では新しいユーザーを作成したので、特定の既存ユーザーを探す必要は基本的にはありませんでした。 今回は、「特定のユーザーに」メモを追加するので、まずその「特定のユーザー」が本当に存在するのか、リクエストで送られてきた userId を使って探さなければなりません。
// routes/users.js の POST /users/:userId/notes 内
// 5. 指定されたユーザーIDに一致するユーザーを探します。
const user = usersData.users.find(u => u.id === userId);
// 6. ユーザーが見つからない場合は404 Not Foundエラーを返します。
if (!user) {
return res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
usersData.users.find(user => user.id === userId) で、ユーザーの配列 usersData.users の中から、ステップ5で取得した userId と id プロパティが一致する最初のユーザーオブジェクトを探し出し、変数 user に格納します。
if (!user) ブロックで、もし該当するユーザーが見つからなければ、user は undefined となります。その場合は、404 Not Found エラーをクライアントに返して処理を終了します。
6. 新しい「ノート」オブジェクトの作成
対象のユーザーが見つかったら、次はそのユーザーに追加する新しいノート(メモ)のデータオブジェクトを作成します。
POST /users で newUser オブジェクトを作成したのと同じ要領です。
// 7. 新しいノートオブジェクトを作成します。IDは自動生成します。
const newNote = {
id: getNextNoteId(user),
content: content,
created_at: new Date().toISOString()
};
ここでは getNextNoteId(userId) という新しいヘルパー関数を呼び出してIDを生成しています。
7. データの更新と保存
新しいノートオブジェクトが作成できたら、それを対象ユーザーの notes 配列に追加し、変更を users.json ファイルに保存します。 POST /users では、新しいユーザーをユーザーリストの末尾に追加しました。今回は、特定のユーザーオブジェクト (user) が持つ notes 配列に、新しいノート (newNote) を追加します。
// 8. ユーザーのノートリストに新しいノートを追加します。
user.notes.push(newNote);
// 9. 更新されたユーザーデータ全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
user.notes.push(newNote) は、ステップ6で取得した user オブジェクトの notes プロパティ(配列)に、ステップ5で作成した newNote オブジェクトを追加します。
writeUsersData(usersData) は、ヘルパー関数を使い、変更が加えられた usersData オブジェクト全体を users.json ファイルに書き戻し、データを永続化します。
8 成功レスポンスの返却
データが無事に保存されたら、クライアントに成功したことを伝え、作成された新しいノートの情報を返します。 これも POST /users と同じ考え方です。
// 10. ノート作成成功を示すステータスコード201 (Created) と共に、
// 作成されたノートの情報をJSONでクライアントに返します。
res.status(201).json(newNote);
9. エラーハンドリング
} catch (error) {
// 11. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in POST /users/:userId/notes: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ノートの作成に失敗しました。' });
}
PATCH /users/:userId/notes/:noteId : の実装
既存のユーザー情報更新API (PATCH /users/:id) を参考に実装します。(解説は省きます。)
/**
* PATCH /users/:userId/notes/:noteId
* 指定されたユーザーIDとノートIDに対してノートの内容を更新します。
* 更新する内容はリクエストボディにJSON形式で含めます。
* 例: { "content": "Updated note content." }
*/
router.patch('/:userId/notes/:noteId', (req, res) => {
const userId = parseInt(req.params.userId, 10);
const noteId = parseInt(req.params.noteId, 10);
console.log(`[${new Date().toISOString()}] PATCH /users/${userId}/notes/${noteId} (handled by routes/users.js) with body:`, req.body);
try {
// 1. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
// 2. 指定されたユーザーIDに一致するユーザーを探します。
const user = usersData.users.find(u => u.id === userId);
// 3. ユーザーが見つからない場合は404 Not Foundエラーを返します。
if (!user) {
return res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
// 4. ユーザーのノートリストから、指定されたノートIDに一致するノートを探します。
const noteIndex = user.notes.findIndex(note => note.id === noteId);
// 5. ノートが見つからない場合は404 Not Foundエラーを返します。
if (noteIndex === -1) {
return res.status(404).json({ message: `ノートが見つかりません (ID: ${noteId})` });
}
// 6. リクエストボディから更新内容を取得します。
const { content } = req.body;
// 7. 入力値の簡易的なバリデーション: contentが提供されているかを確認します。
if (!content) {
return res.status(400).json({ message: 'ノートの内容は必須です。' });
}
// 8. ノートの内容を更新します。
user.notes[noteIndex].content = content;
// 9. 更新されたユーザーデータ全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
// 10. 更新後のノート情報をJSONでクライアントに返します。
res.json(user.notes
[noteIndex]);
} catch (error) {
// 11. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in PATCH /users/${userId}/notes/${noteId}: ${error.message}`);
res.status(500).json({ message: 'サーバーエラー: ノートの更新に失敗しました。' });
}
});
DELETE /users/:userId/notes/:noteId : の実装
既存のユーザー情報削除API (PATCH /users/:id) を参考に実装します。(解説は省きます。)
/**
* DELETE /users/:userId/notes/:noteId
* 指定されたユーザーIDとノートIDに対してノートを削除します。
*/
router.delete('/:userId/notes/:noteId', (req, res) => {
const userId = parseInt(req.params.userId, 10);
const noteId = parseInt(req.params.noteId, 10);
console.log(`[${new Date().toISOString()}] DELETE /users/${userId}/notes/${noteId} (handled by routes/users.js)`);
try {
// 1. 現在のユーザーデータをファイルから読み込みます。
const usersData = readUsersData();
// 2. 指定されたユーザーIDに一致するユーザーを探します。
const user = usersData.users.find(u => u.id === userId);
// 3. ユーザーが見つからない場合は404 Not Foundエラーを返します。
if (!user) {
return res.status(404).json({ message: `ユーザーが見つかりません (ID: ${userId})` });
}
// 4. ユーザーのノートリストから、指定されたノートIDに一致するノートを探します。
const noteIndex = user.notes.findIndex(note => note.id === noteId);
// 5. ノートが見つからない場合は404 Not Foundエラーを返します。
if (noteIndex === -1) {
return res.status(404).json({ message: `ノートが見つかりません (ID: ${noteId})` });
}
// 6. ノートを削除します。配列のfilterメソッドを使って、指定されたノート以外のものを残します。
user.notes.splice(noteIndex, 1);
// 7. 更新されたユーザーデータ全体をファイルに書き戻し、永続化します。
writeUsersData(usersData);
// 8. 削除成功を示すステータスコード204 (No Content) を返します。
res.status(204).send();
} catch (error) {
// 9. 処理中に予期せぬエラーが発生した場合、500 Internal Server Errorを返します。
console.error(`[${new Date().toISOString()}] Error in DELETE /users/${userId}/notes/${noteId}: ${error
.message}`);
res.status(500).json({ message: 'サーバーエラー: ノートの削除に失敗しました。' });
}
});
これで、ユーザーのメモを新規作成する POST /users/:userId/notes と、既存のメモを更新する PATCH /users/:userId/notes/:noteId 、 既存のメモを削除する DELETE /users/:userId/notes/:noteId の3つの新しいAPIエンドポイントがモックサーバーに実装されました。
ここまで実装すれば、ターミナルで node server.js コマンドを使ってサーバーを起動することで、これらの新しい仕様を含んだモックAPIが動作する状態になります。
LEVEL-2 プロキシを利用した透過的モックサーバー
さて、ここまでは指定したエンドポイントに対して用意したモックデータを返す、基本的なモックサーバーを Express で構築してきました。この方法は、特定のAPIエンドポイントをピンポイントでモック化し、それを利用する機能の開発やテストを進める上では非常に有効です。
しかし、思い出していただきたいのは、このLEVEL-1のアプローチでは「モックサーバーに実装されていないAPIエンドポイントへのリクエストは処理できない」という点でした。アプリケーションが多くのAPIを利用しており、その一部だけをモック化したい場合、モック化していないAPIを呼び出すとエラーになってしまい、アプリケーション全体の動作確認が難しいことがあります。
このセクションでは、LEVEL-1 のアプローチで作成したモックサーバーにプロキシ機能を追加します。これにより、以下のような動作が実現できます。
- モックとして実装したエンドポイントへのリクエスト: これまで通り、モックサーバーが処理し、定義したモックデータを返します。
- それ以外のエンドポイントへのリクエスト: モックサーバーが窓口となりつつも、そのリクエストを自動的に実際のバックエンドAPIサーバーへ転送(プロキシ)し、バックエンドからのレスポンスをそのままアプリケーションに返します。
アプリケーション全体でAPIの接続先を一つしか設定できない場合や、多数のAPIが存在する中で一部のAPIだけを新しい仕様に合わせて段階的にモック化したい場合に、この透過的プロキシは非常に強力な解決策となります。
このプロキシ機能を実現する方法として、ここでは、Express のミドルウェアである http-proxy-middleware を利用します。
http-proxy-middleware の導入
まず、プロジェクトに http-proxy-middleware をインストールします。ターミナルで以下のコマンドを実行します。
npm install http-proxy-middleware
server.js の変更
次に、server.js のコードを変更し、プロキシ機能を追加します。
この変更では http-proxy-middleware を利用します。これにより、開発サーバー(localhost:3000)で受け付けるリクエストのうち、モック化したエンドポイント(/users)以外のものは、実際のAPIサーバー(本来のリクエスト先ドメイン)へ転送されるようになります。
ここでは仮に {JSON} Placeholder にリクエストを転送しています。
/**
* このファイルは、Express.js を使用してAPIモックサーバーを起動するためのメインスクリプトです。
* ローカルでのモックAPI処理と、指定外のAPIリクエストを実際のバックエンドへ転送する
* プロキシ機能を提供します。
*/
// 必要なモジュールの読み込み
const express = require('express');
const path = require('path');
const { createProxyMiddleware } = require('http-proxy-middleware'); // http-proxy-middleware を読み込み
// --- プロキシターゲット設定 (重要: ご自身の環境に合わせて実際の値に書き換えてください) ---
// ここに、実際にリクエストを転送したいバックエンドAPIサーバーの情報を直接記述します。
const TARGET_API_SERVER = 'https://jsonplaceholder.typicode.com'; // 例: 'https://api.your-backend.com'
const TARGET_API_HOSTNAME_FOR_HEADER = 'jsonplaceholder.typicode.com'; // 例: 'api.your-backend.com' (通常はTARGET_API_SERVERのホスト名部分)
// Expressアプリケーションのインスタンスを作成
const app = express();
const port = 3000;
// --- 共通ミドルウェアの設定 ---
app.use(express.json()); // JSONパーサー
// --- モックAPIルートの読み込みとマウント ---
// `/users` へのリクエストはこちらのモックAPIで処理されます。
const userRoutes = require('./routes/users');
app.use('/users', userRoutes);
// (他のモックAPIルートがあれば、同様に追加できます)
// --- プロキシ設定 ---
const proxyOptions = {
target: TARGET_API_SERVER, // プロキシ先のターゲットサーバー
changeOrigin: true, // ホストヘッダーをターゲットのホストに変更
secure: false, // HTTPSを使用する場合は、ターゲットサーバーの証明書検証を無効化(開発環境向け)
logLevel: 'debug', // ログレベルをデバッグに設定(必要に応じて 'info' や 'warn' に変更可能)
on: {
// プロキシイベントのハンドラを定義
// リクエストがターゲットサーバーに転送される前の処理
proxyReq: (proxyReq, req, res) => {
proxyReq.setHeader('Host', TARGET_API_HOSTNAME_FOR_HEADER);
console.log(`[${new Date().toISOString()}] [PROXY_REQ] Forwarding ${req.method} ${req.originalUrl} to: ${TARGET_API_SERVER}${proxyReq.path} (Host: ${proxyReq.getHeader('Host')})`);
},
// ターゲットサーバーからのレスポンスを受け取った後の処理
proxyRes: (proxyRes, req, res) => {
console.log(`[${new Date().toISOString()}] [PROXY_RES] Received from target for ${req.method} ${req.originalUrl} - Status: ${proxyRes.statusCode}`);
},
// プロキシエラーが発生した場合の処理
error: (err, req, res) => {
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Proxy Error for ${req.method} ${req.originalUrl}:`, err);
if (res && res.writeHead && !res.headersSent) {
// レスポンスヘッダーがまだ送信されていない場合にエラーレスポンスを送信
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Sending error response for ${req.originalUrl}`);
// 502 Bad Gateway エラーをクライアントに返す
// ここでは、JSON形式でエラーメッセージを返します。
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Proxy error: Could not connect to target server.', error: err.message }));
} else if (res && res.headersSent) {
// レスポンスヘッダーが既に送信されている場合は、エラーメッセージをログに出力
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Headers already sent for ${req.originalUrl}, cannot send custom error to client.`);
} else {
// レスポンスオブジェクトが利用できない場合のエラーログ
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Response object not available for ${req.originalUrl}, cannot send error to client.`);
}
},
// プロキシ接続が開かれたときの処理
open: (proxySocket) => {
console.log(`[${new Date().toISOString()}] [PROXY_EVENT] Connection to target server opened.`);
},
// プロキシ接続が閉じられたときの処理
close: (proxyRes, proxySocket, head) => {
console.log(`[${new Date().toISOString()}] [PROXY_EVENT] Connection to target server closed.`);
}
}
};
// プロキシミドルウェアの作成
const defaultProxy = createProxyMiddleware(proxyOptions);
// --- プロキシミドルウェアの適用 (重要: モックAPIルート定義の後に配置) ---
// すべてのリクエストをプロキシミドルウェアに渡します。
app.use(defaultProxy);
// --- Express全体のエラーハンドラ ---
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] [APP_ERR] Unhandled application error in mock server:`, err);
if (!res.headersSent) {
res.status(err.status || 500).json({
message: 'Application error in mock server',
error: err.message,
});
}
});
// --- サーバーの起動 ---
app.listen(port, () => {
console.log(`\nMock API Server with Proxy is running at http://localhost:${port}`);
console.log(`--------------------------------------------------------------------------------`);
console.log(` >> Application should be configured to send API requests to: http://localhost:${port}`);
console.log(`\n >> Mocked API Endpoints (handled locally):`);
console.log(` - User API: http://localhost:${port}/users`);
console.log(`\n >> Requests to all other paths will be proxied to: ${TARGET_API_SERVER}`);
console.log(` (Using Host header: ${TARGET_API_HOSTNAME_FOR_HEADER})`);
console.log(`\n User data for mocks is stored in: ${path.join(__dirname, 'resource', 'users.json')}`);
console.log(`--------------------------------------------------------------------------------\n`);
});
実装の詳細説明はコードコメントを参照してください。
動作確認
node server.js を実行してモックサーバーを起動します。ターミナルには以下のようなログが出力され、サーバーがポート3000 で待機状態になったことが分かります。
myuser@mypc uncoming-api % node server.js
Mock API Server with Proxy is running at http://localhost:3000
--------------------------------------------------------------------------------
>> Application should be configured to send API requests to: http://localhost:3000
>> Mocked API Endpoints (handled locally):
- User API: http://localhost:3000/users
>> Requests to all other paths will be proxied to: https://jsonplaceholder.typicode.com
User data for mocks is stored in: /Users/myuser/uncoming-api/resource/users.json
--------------------------------------------------------------------------------
次に、実際にいくつかのエンドポイントにリクエストを送信してみます。ここでは、以下の3つのリクエストを送信したケースを想定します。
- モック定義されている /users
- モック定義されておらず、プロキシ先に存在しない /articles (意図的に404エラーを発生させる例)
- モック定義されておらず、プロキシ先に存在する /todos
これらのリクエストを送信すると、サーバーのコンソールには以下のようなログが追記されます。
[2025-05-28T04:06:50.327Z] GET /users (handled by routes/users.js)
[2025-05-28T04:07:18.646Z] [PROXY_REQ] Forwarding GET /arcicle to: https://jsonplaceholder.typicode.com/arcicles (Host: jsonplaceholder.typicode.com)
[2025-05-28T04:07:19.430Z] [PROXY_RES] Received from target for GET /arcicles - Status: 404
[2025-05-28T04:08:30.804Z] [PROXY_REQ] Forwarding GET /todos to: https://jsonplaceholder.typicode.com/todos (Host: jsonplaceholder.typicode.com)
[2025-05-28T04:08:30.903Z] [PROXY_RES] Received from target for GET /todos - Status: 200
ログから各リクエストの処理結果を確認できます。
- http://localhost:3000/users へのリクエスト:
- ログ: GET /users (handled by routes/users.js)
- これは事前にモックとして定義したエンドポイントなので、ローカルのモックデータ(routes/users.js 経由)が返されます。
- http://localhost:3000/articles へのリクエスト:
- ログ: [PROXY_REQ] Forwarding GET /articles to: https://jsonplaceholder.typicode.com/articles
- ログ: [PROXY_RES] Received from target for GET /articles - Status: 404
- これはモック定義されていないため、リクエストは https://jsonplaceholder.typicode.com/articles へ転送(プロキシ)されました。転送先にはこのパスが存在しないため、404 Not Found のステータスコードが返されています。
- http://localhost:3000/todos へのリクエスト:
- ログ: [PROXY_REQ] Forwarding GET /todos to: https://jsonplaceholder.typicode.com/todos
- ログ: [PROXY_RES] Received from target for GET /todos - Status: 200
- これもモック定義されていないため、リクエストは https://jsonplaceholder.typicode.com/todos へ転送されました。転送先にはこのパスが存在し、正常に処理されたため、200 OK のステータスコードが返されています。
このように、モックサーバーは定義済みのエンドポイントにはモックレスポンスを、それ以外のエンドポイントには指定されたバックエンドサーバーへの透過的なプロキシとして機能していることが確認できました。
LEVEL-3 hostsファイルを利用したリクエストの振り分け
ここからは、次のレベルです。
これまでの方法では、モックサーバーを利用する際に、アプリケーション側のAPI接続設定を直接書き換える必要がありました。しかし、実際の開発では「既存コードには極力手を加えたくない」「そもそも設定変更が難しい」といった状況も少なくありません。
ここでは、この課題を解決するため、アプリケーションのコードには一切触れないアプローチを取ります。それは、開発用PCのhostsファイルを編集し、特定のドメイン宛の通信を、強制的に自分自身のPC(localhost)へ振り向けてしまう、という強力な手法です。
手順の概要
設定は、大まかに以下のステップで進めます。
- ドメインの名前解決をローカルPCに向ける(hosts ファイルの編集)
- ローカル認証局(CA)とサーバー証明書の準備(mkcert の利用)
- 証明書の安全な管理と設定の準備(.gitignore, 環境変数)
- Node.js サーバー(server.js)のHTTPS対応
- アプリケーションからの利用
1. ドメインの名前解決をローカルPCに向ける
まず、開発対象ドメイン(この例では jsonplaceholder.typicode.com)へのアクセスが、インターネット上の実サーバーではなく、開発PC(IPアドレス 127.0.0.1)へ直接向かうようにします。これにより、テスト対象のアプリケーションは普段通りドメイン名でAPIを呼び出しつつ、実際にはローカルで動作するサーバーと通信できるようになります。
この設定変更は、PCの hosts ファイルで行います。 ターミナルを開き、以下のコマンドを実行してhostsファイルを編集可能な状態で開いてください(実行時に管理者パスワードの入力が求められます)。
hosts ファイルは、macOS や Linux では /etc/hosts、Windows では C:\Windows\System32\drivers\etc\hosts にあります。本記事は macOS を前提に解説を進めます。
sudo nano /etc/hosts
エディタが開いたら、ファイルの末尾に以下の1行を追加します。この記述により、jsonplaceholder.typicode.com へのリクエストが、ローカルマシン(127.0.0.1)に解決されるようになります。
127.0.0.1 jsonplaceholder.typicode.com
【重要】 hosts ファイル内に元々記述されている 127.0.0.1 localhost や ::1 localhost といった行は、OSの正常なネットワーク機能に不可欠です。これらの行は絶対に削除したり、行頭に # をつけてコメントアウトしたりしないでください。
上記を追記したら、ファイルを保存してエディタを終了します(nanoエディタの場合、Ctrl + O を押してファイル名を確定(Enter)、その後 Ctrl + X で終了)。
2. ローカル認証局(CA)とサーバー証明書の準備
ローカル開発環境でブラウザ等に信頼されるHTTPS通信を実現するために、mkcert というツールを使用してSSL証明書を準備します。
mkcert のインストール
まず、mkcert を開発PCにインストールします。macOS で Homebrew を利用している場合は、ターミナルで以下のコマンドを実行します。
brew install mkcert
ローカル認証局(CA)のセットアップ
次に、mkcert を使って、開発PC内に信頼される証明書発行元(ローカル認証局、CA)を作成し、システムに登録します。この作業を行うことで、このローカルCAが発行したサーバー証明書を、ブラウザなどが自動的に「信頼できる」と判断するようになります。
以下のコマンドを実行します。
mkcert -install
これにより、ローカルCAが作成され、そのCA証明書がシステムのトラストストアにインストールされます。
ドメイン用サーバー証明書の発行
ローカルCAの準備ができたら、実際にNode.jsサーバーで使用するドメイン用のサーバー証明書と秘密鍵を発行します。この証明書は、上記でセットアップしたローカルCAによって署名されます。
以下のコマンドの jsonplaceholder.typicode.com の部分を、実際に開発で使用するドメイン名(ステップ1でhostsファイルに設定したもの)に置き換えて実行してください。
mkcert jsonplaceholder.typicode.com
【重要】 ここで指定するドメイン名は、ステップ1の hosts ファイルで 127.0.0.1 に紐付けたドメイン名と完全に一致させる必要があります。
このコマンドを実行すると、カレントディレクトリに ドメイン名.pem(サーバー証明書)と ドメイン名-key.pem(秘密鍵)という2つのファイルが生成されます。これらのファイルを後ほど Node.js サーバーに設定することで、対象ドメインでのHTTPS通信が可能になります。
3. 証明書の安全な管理と設定の準備
Node.js サーバー(server.js)で HTTPS を有効にするために必要なサーバー証明書と秘密鍵のパスを、安全かつ柔軟に管理するための準備を行います。ソースコードに直接パスを書き込むのではなく、環境変数を使用する方法を採用します。
dotenv パッケージのインストール
証明書ファイルのパスなどの設定情報を、ソースコードとは分離された .env ファイルから読み込み、環境変数として Node.js アプリケーションに提供するために dotenv パッケージを利用します。
プロジェクトのルートディレクトリで、ターミナルから以下のコマンドを実行して dotenv をインストールします。
npm install dotenv
証明書ファイルの整理・配置
ステップ2で mkcert を使って生成したサーバー証明書ファイル(例: jsonplaceholder.typicode.com.pem)と秘密鍵ファイル(例: jsonplaceholder.typicode.com-key.pem)を、プロジェクト内で管理しやすいように専用のディレクトリに配置します。
- プロジェクトのルートに certs という名前のディレクトリを作成します。
- 生成された証明書ファイルと秘密鍵ファイルを、この certs ディレクトリ内に移動します。
以下のようなプロジェクト構造になります。
uncoming-api/
├── certs/
│ ├── jsonplaceholder.typicode.com.pem # (mkcertで生成したサーバー証明書)
│ └── jsonplaceholder.typicode.com-key.pem # (mkcertで生成した秘密鍵)
├── node_modules/
├── resource/
│ └── users.json
├── routes/
│ └── users.js
├── server.js
├── package.json
├── .env
├── .env.template
└── .gitignore
環境変数設定ファイルの作成 (.env と .env.template)
プロジェクトのルートに .env という名前のファイルを作成します。この .env ファイルには、実際の開発環境に合わせた値を設定します。.env ファイルは、具体的なパスや秘密情報を含む可能性があるため、バージョン管理システムには含めません(後述の .gitignore で指定)。
SSL_CERT_PATH には、certs ディレクトリに配置したサーバー証明書ファイルへのパスを記述します。同様に、SSL_KEY_PATH には秘密鍵ファイルへのパスを設定してください。相対パスでも絶対パスでも構いません。
HTTPS_PORT は、HTTPSサーバーがリッスンするポート番号です。通常は 443 が使用されますが、必要に応じて別のポート番号(例: 8443)に変更します。
TARGET_API_IP には、プロキシしたいAPIサーバーのIPアドレスを設定します。このIPアドレスは、Google Public DNS などのツールで対象のサーバーURL(例: https://jsonplaceholder.typicode.com)を入力すると確認できます。 例えば、Google Public DNS で jsonplaceholder.typicode.com を検索すると、以下の例の "data": "104.21.64.1" のような形式でIPアドレスが返ってきます。
{
"name": "jsonplaceholder.typicode.com.",
"type": 1 /* A */,
"TTL": 300,
"data": "104.21.64.1"
},
複数のIPアドレスが見つかる場合は、DNSラウンドロビンなどで冗長化されているため、そのうちのどれを使用しても問題ありません。
# SSL証明書と秘密鍵のパス
# certsディレクトリ内に配置したファイル名を指定
SSL_CERT_PATH=./certs/jsonplaceholder.typicode.com.pem
SSL_KEY_PATH=./certs/jsonplaceholder.typicode.com-key.pem
# HTTPSサーバーのポート番号
HTTPS_PORT=443
# プロキシ先APIサーバーのURL
TARGET_API_SERVER=https://jsonplaceholder.typicode.com
# プロキシ先APIサーバーのIPアドレス(https://dns.google/)
TARGET_API_IP=104.21.64.1
必要に応じて、プロジェクトのルートに .env.template という名前で、必要な環境変数の雛形(テンプレート)ファイルを作成します。
このファイルはバージョン管理システム(Gitなど)に含め、他の開発者がどのような環境設定が必要かを把握できるようにするためのものです。
# SSL証明書と秘密鍵のパス
# certsディレクトリ内に配置したファイル名を指定
SSL_CERT_PATH=./certs/target-domain.com.pem
SSL_KEY_PATH=./certs/v.com-key.pem
# HTTPSサーバーのポート番号
HTTPS_PORT=443
# プロキシサーバーのホスト名(hostsファイルで127.0.0.1に向ける対象ドメイン名)
TARGET_DOMAIN_FOR_HOSTS=target-domain.com
# プロキシサーバーのFQDN(フルクオリファイドドメイン名)
TARGET_API_SERVER_FQDN=https://target-domain.com
# プロキシ先APIサーバーのIPアドレス(https://dns.google/)
TARGET_API_IP=000.00.00.0
.gitignore の設定
セキュリティを確保し、ローカル環境固有の設定が誤ってリポジトリに共有されるのを防ぐため、以下のファイルやディレクトリを .gitignore ファイルに追加します。 プロジェクトのルートにある .gitignore ファイル(存在しない場合は新規作成)に、以下の内容を追記してください。
# 環境変数ファイル
.env
# mkcertでローカル生成したSSL証明書ファイル
certs/*.pem
4:Node.jsサーバー(server.js)のHTTPS対応
いよいよ、これまでに準備した証明書や環境変数を活用し、server.js をHTTPS対応のプロキシモックサーバーとして完成させます。
まずは、完成形のコード全体を確認します。
server.js
/**
* このファイルは、Express.js を使用してAPIモックサーバーを起動するためのメインスクリプトです。
* ローカルでのモックAPI処理、指定外のAPIリクエストを実際のバックエンドへ転送するプロキシ機能、
* および mkcert で生成された証明書を使用したローカルHTTPS機能を提供します。
*/
// .envファイルから環境変数を読み込む (ファイルの先頭で実行)
require('dotenv').config();
// 必要なモジュールの読み込み
const express = require('express');
const path = require('path');
const { createProxyMiddleware } = require('http-proxy-middleware');
const https = require('https'); // HTTPSモジュールを追加
const fs = require('fs'); // ファイルシステムモジュールを追加
const { URL } = require('url'); // URLモジュールを追加して URL パーシングを安全に
// --- 環境変数からの設定読み込み ---
const sslCertPath = process.env.SSL_CERT_PATH;
const sslKeyPath = process.env.SSL_KEY_PATH;
// HTTPSポートを環境変数から取得、なければデフォルトで443を使用
const httpsPort = parseInt(process.env.HTTPS_PORT, 10) || 443;
// --- プロキシターゲット設定 ---
// クライアントがアクセスするドメイン名(hostsで127.0.0.1に向ける対象)
const TARGET_DOMAIN_FOR_HOSTS = process.env.TARGET_DOMAIN_FOR_HOSTS || 'jsonplaceholder.typicode.com';
// 実際にリクエストを転送したいバックエンドAPIサーバーのFQDN (例: https://jsonplaceholder.typicode.com)
const TARGET_API_SERVER_FQDN = process.env.TARGET_API_SERVER_FQDN || 'https://jsonplaceholder.typicode.com';
// プロキシ先APIサーバーの実際のIPアドレス (hostsファイルが介入しないように直接指定)
const TARGET_API_IP = process.env.TARGET_API_IP || '104.21.64.1';
// Expressアプリケーションのインスタンスを作成
const app = express();
// --- 共通ミドルウェアの設定 ---
app.use(express.json()); // JSONパーサー
// --- モックAPIルートの読み込みとマウント ---
// `/users` へのリクエストはこちらのモックAPIで処理されます。
const userRoutes = require('./routes/users');
app.use('/users', userRoutes);
// (他のモックAPIルートがあれば、同様に追加できます)
// --- プロキシ設定 ---
// プロキシのターゲットは実際のIPアドレス(hostsの影響を受けない)
const proxyOptions = {
target: `https://${TARGET_API_IP}`, // <<<< 無限ループ回避のため、IPアドレスを直接ターゲットにする
changeOrigin: true, // ホストヘッダーをターゲットのホストに変更 (ただし proxyReq で上書きします)
secure: false, // HTTPSを使用する場合は、ターゲットサーバーの証明書検証を無効化(開発環境向け)
logLevel: 'debug', // ログレベルをデバッグに設定
agent: new https.Agent({
rejectUnauthorized: false, // ターゲットサーバーの証明書検証を無効化(開発環境向け)
servername: new URL(TARGET_API_SERVER_FQDN).hostname, // SNI対応のため、サーバーネームを設定
}),
on: {
proxyReq: (proxyReq, req, res) => {
// リクエストヘッダーの 'Host' を実際のドメイン名に設定
// ターゲットがIPアドレスでも、相手サーバーはこのHostヘッダーを見てルーティングします。
proxyReq.setHeader('Host', new URL(TARGET_API_SERVER_FQDN).hostname);
console.log(`[${new Date().toISOString()}] [PROXY_REQ] Forwarding ${req.method} ${req.originalUrl} to: ${proxyOptions.target}${proxyReq.path} (Host: ${proxyReq.getHeader('Host')})`);
},
proxyRes: (proxyRes, req, res) => {
// ターゲットサーバーからのレスポンスを受け取った後の処理
console.log(`[${new Date().toISOString()}] [PROXY_RES] Received from target for ${req.method} ${req.originalUrl} - Status: ${proxyRes.statusCode}`);
},
// プロキシエラーが発生した場合の処理
error: (err, req, res) => {
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Proxy Error for ${req.method} ${req.originalUrl}:`, err);
if (res && res.writeHead && !res.headersSent) {
// レスポンスヘッダーがまだ送信されていない場合にエラーレスポンスを送信
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Sending error response for ${req.originalUrl}`);
// 502 Bad Gateway エラーをクライアントに返す
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Proxy error: Could not connect to target server.', error: err.message }));
} else if (res && res.headersSent) {
// レスポンスヘッダーが既に送信されている場合は、エラーメッセージをログに出力
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Headers already sent for ${req.originalUrl}, cannot send custom error to client.`);
} else {
// レスポンスオブジェクトが利用できない場合のエラーログ
console.error(`[${new Date().toISOString()}] [PROXY_ERR] Response object not available for ${req.originalUrl}, cannot send error to client.`);
}
},
// プロキシ接続が開かれたときの処理
open: (proxySocket) => {
console.log(`[${new Date().toISOString()}] [PROXY_EVENT] Connection to target server opened.`);
},
// プロキシ接続が閉じられたときの処理
close: (proxyRes, proxySocket, head) => {
console.log(`[${new Date().toISOString()}] [PROXY_EVENT] Connection to target server closed.`);
}
}
};
// プロキシミドルウェアの作成
const defaultProxy = createProxyMiddleware(proxyOptions);
// --- プロキシミドルウェアの適用 (モックAPIルート定義の後に配置) ---
app.use(defaultProxy);
// --- Express全体のエラーハンドラ ---
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] [APP_ERR] Unhandled application error in mock server:`, err);
if (!res.headersSent) {
// レスポンスヘッダーがまだ送信されていない場合にエラーレスポンスを送信
console.error(`[${new Date().toISOString()}] [APP_ERR] Sending error response for ${req.originalUrl}`);
// 500 Internal Server Error エラーをクライアントに返す
res.status(err.status || 500).json({
message: 'Application error in mock server',
error: err.message,
});
}
});
// --- HTTPSサーバーの起動準備 ---
let httpsOptions;
try {
// SSL証明書と秘密鍵のパスを環境変数から取得
if (!sslCertPath || !sslKeyPath) {
throw new Error('SSL_CERT_PATH and SSL_KEY_PATH must be defined in your .env file.');
}
// SSL証明書と秘密鍵をファイルから読み込む
httpsOptions = {
key: fs.readFileSync(sslKeyPath),
cert: fs.readFileSync(sslCertPath),
};
} catch (error) {
// 設定ファイルが読み込めない場合はエラーメッセージを表示して終了
console.error('[ERROR] Failed to read SSL certificate/key files.');
console.error('Please ensure that SSL_CERT_PATH and SSL_KEY_PATH in your .env file point to valid files generated by mkcert.');
console.error('Error details:', error.message);
process.exit(1); // エラーが発生した場合はプロセスを終了
}
// --- HTTPSサーバーの作成と起動 ---
const httpsServer = https.createServer(httpsOptions, app);
httpsServer.listen(httpsPort, () => {
// サーバーが正常に起動したことを示すメッセージをコンソールに出力
console.log(`\nHTTPS Mock API Server with Proxy is running.`);
console.log(`--------------------------------------------------------------------------------`);
console.log(` >> Listening on: https://localhost:${httpsPort}`);
console.log(` >> Your 'hosts' file should point '${TARGET_DOMAIN_FOR_HOSTS}' to 127.0.0.1.`);
console.log(` >> Application should send API requests to its original target domain`);
console.log(` (e.g., https://${TARGET_DOMAIN_FOR_HOSTS}/some-api-path)`);
console.log(`\n >> Mocked API Endpoints (handled locally):`);
console.log(` - User API: /users (accessed via https://${TARGET_DOMAIN_FOR_HOSTS}/users)`);
console.log(`\n >> Requests to all other paths will be proxied to: ${TARGET_API_SERVER_FQDN} (via IP: ${TARGET_API_IP})`);
console.log(`\n User data for mocks is stored in: ${path.join(__dirname, 'resource', 'users.json')}`);
console.log(`--------------------------------------------------------------------------------\n`);
// ポート443など特権ポートでsudoなしで起動しようとした場合の警告 (Linux/macOS向け)
if (httpsPort < 1024 && process.getuid && process.getuid() !== 0) {
console.warn(`[WARNING] Server started on a privileged port (${httpsPort}) without root privileges.`);
console.warn(` This might not work on all systems. If it fails, try running with 'sudo'.`);
}
});
実装のポイント
ここからは、上記の server.js コードにおける重要な変更点や設定の意図について解説します。
環境変数の利用
サーバーの設定に、.env ファイルで定義した環境変数を利用します。これにより、環境ごとの設定変更が容易になり、認証情報などをコードに直接書かずに済みます。
- sslCertPath, sslKeyPath: mkcert で生成したSSL証明書と秘密鍵へのパス。
- httpsPort: HTTPSサーバーがリッスンするポート番号。デフォルトは 443 です。
- TARGET_DOMAIN_FOR_HOSTS: hosts ファイルで 127.0.0.1 に向けるターゲットドメイン名。クライアントアプリがアクセスする対象です。
- TARGET_API_SERVER_FQDN: プロキシが外部APIサーバーへリクエストを転送する際に、Host ヘッダーとして使用する本来のFQDN(例: https://jsonplaceholder.typicode.com)。
- TARGET_API_IP: プロキシが実際に接続するターゲットAPIサーバーのIPアドレス。hosts ファイルによる名前解決の無限ループを回避するために、IPアドレスを直接指定します。
HTTPSサーバーの構築
http モジュールではなく https モジュールを使用し、読み込んだSSL証明書と秘密鍵をサーバー起動時に渡します。これにより、ローカル環境でHTTPS通信が実現します。
// --- HTTPSサーバーの起動準備 ---
let httpsOptions;
try {
// SSL証明書と秘密鍵のパスを環境変数から取得
if (!sslCertPath || !sslKeyPath) {
throw new Error('SSL_CERT_PATH and SSL_KEY_PATH must be defined in your .env file.');
}
// SSL証明書と秘密鍵をファイルから読み込む
httpsOptions = {
key: fs.readFileSync(sslKeyPath),
cert: fs.readFileSync(sslCertPath),
};
} catch (error) {
// 設定ファイルが読み込めない場合はエラーメッセージを表示して終了
console.error('[ERROR] Failed to read SSL certificate/key files.');
console.error('Please ensure that SSL_CERT_PATH and SSL_KEY_PATH in your .env file point to valid files generated by mkcert.');
console.error('Error details:', error.message);
process.exit(1); // エラーが発生した場合はプロセスを終了
}
// --- HTTPSサーバーの作成と起動 ---
const httpsServer = https.createServer(httpsOptions, app);
プロキシターゲットの設定
プロキシ設定の最も重要なポイントは、http-proxy-middleware の target と agent オプションの組み合わせです。
proxyOptions の target には IPアドレスを指定するようにします。(例:https://172.64.32.182)これは、hosts でファイルで設定した名前解決が無限ループしてしまうのを防ぐためです。
// --- プロキシ設定 ---
// プロキシのターゲットは実際のIPアドレス(hostsの影響を受けない)
const proxyOptions = {
target: `https://${TARGET_API_IP}`, // <<<< 無限ループ回避のため、IPアドレスを直接ターゲットにする
changeOrigin: true, // ホストヘッダーをターゲットのホストに変更 (ただし proxyReq で上書きします)
secure: false, // HTTPSを使用する場合は、ターゲットサーバーの証明書検証を無効化(開発環境向け)
logLevel: 'debug', // ログレベルをデバッグに設定
agent: new https.Agent({
rejectUnauthorized: false, // ターゲットサーバーの証明書検証を無効化(開発環境向け)
servername: new URL(TARGET_API_SERVER_FQDN).hostname, // SNI対応のため、サーバーネームを設定
}),
on: {
proxyReq: (proxyReq, req, res) => {
// リクエストヘッダーの 'Host' を実際のドメイン名に設定
proxyReq.setHeader('Host', new URL(TARGET_API_SERVER_FQDN).hostname);
console.log(`[${new Date().toISOString()}] [PROXY_REQ] Forwarding ${req.method} ${req.originalUrl} to: ${proxyOptions.target}${proxyReq.path} (Host: ${proxyReq.getHeader('Host')})`);
},
// ...
},
// ...
}
};
- target: `https://${TARGET_API_IP}`
- ここで、プロキシの転送先をIPアドレスに直接指定します。これにより、Node.js が外部サーバーの名前解決を行う際に hosts ファイルの設定が介入するのを防ぎ、プロキシが自分自身(127.0.0.1)に再度ルーティングされる無限ループを防ぎます。
- agent: new https.Agent({ rejectUnauthorized: false, servername: ... }):
- rejectUnauthorized: false
- プロキシが接続する外部のHTTPSサーバーのSSL証明書を検証しない設定です。これは、企業ネットワークのファイアウォールによるSSLインスペクションなど、特定の開発環境で SELF_SIGNED_CERT_IN_CHAIN エラーが発生する場合に有効です。
- servername: new URL(TARGET_API_SERVER_FQDN).hostname
- target がIPアドレスの場合、TLSハンドシェイク時に必要な SNI (Server Name Indication) が正しく送信されないことがあります。これを明示的に設定することで、IPアドレスで接続しつつも、相手のサーバーに「どのドメイン宛のリクエストか」を正しく伝え、適切なSSL証明書を提示してもらえるようにします。
- rejectUnauthorized: false
- proxyReq.setHeader('Host', new URL(TARGET_API_SERVER_FQDN).hostname):
- HTTPリクエストヘッダー内の Host も、TARGET_API_SERVER_FQDN のホスト名に明示的に設定します。これにより、ターゲットサーバーがバーチャルホスト機能を使っていても、リクエストを正しくルーティングできます。
起動時のログと警告
サーバー起動時に出力されるメッセージは、現在の設定(hosts ファイルによるドメイン名のローカルリダイレクト、HTTPSポート、プロキシ先の情報)を分かりやすく示します。
また、ポート 443 のような特権ポート(1024未満のポート)を sudo なしで起動しようとした場合に警告が表示されます。これは、Linux / macOS 環境で特権ポートの使用には root 権限が必要な場合があるためです。
そのため、モックサーバーの起動には以下のコマンドを実行します。実行時に管理者パスワードを求められます。
sudo node server.js
5:動作確認
これまでに構築したHTTPS対応のモックサーバーが、期待通りに動作するか確認しましょう。ここでは、Webブラウザを通してAPIリクエストを送信し、その挙動を検証します。
モックサーバーを起動すると、コンソールには以下のようなログが出力されます。
myuser@mypc uncoming-api % sudo node server.js
Password:
HTTPS Mock API Server with Proxy is running.
--------------------------------------------------------------------------------
>> Listening on: https://localhost:443
>> Your 'hosts' file should point 'jsonplaceholder.typicode.com' to 127.0.0.1.
>> Application should send API requests to its original target domain
(e.g., https://jsonplaceholder.typicode.com/some-api-path)
>> Mocked API Endpoints (handled locally):
- User API: /users (accessed via https://jsonplaceholder.typicode.com/users)
>> Requests to all other paths will be proxied to: https://jsonplaceholder.typicode.com (via IP: 104.21.64.1)
User data for mocks is stored in: /Users/myuser/uncoming-api/resource/users.json
--------------------------------------------------------------------------------
このログは、サーバーが https://localhost:443 で起動し、hosts ファイルによって jsonplaceholder.typicode.com へのリクエストがローカルに向けられることを示しています。これにより、アプリケーションは https://jsonplaceholder.typicode.com へ通常通りリクエストを送信しつつ、実際にはローカルのモックサーバーがその処理を行います。
実際にいくつかのエンドポイントにリクエストを送信し、モックサーバーの動作を確認します。ここでは、LEVEL-2の検証と同様に、以下の3つのリクエストを送信します。
- モック定義されている /users
- https://jsonplaceholder.typicode.com/users
- モック定義されておらず、プロキシ先に存在しない /articles (意図的に404エラーを発生させる例)
- https://jsonplaceholder.typicode.com/articles
- モック定義されておらず、プロキシ先に存在する /todos
- https://jsonplaceholder.typicode.com/todos
これらのリクエストを送信すると、サーバーのコンソールには以下のようなログが追記されます。
[2025-05-29T00:13:17.609Z] GET /users (handled by routes/users.js)
[2025-05-29T00:14:00.957Z] [PROXY_REQ] Forwarding GET /articles to: https://172.64.32.182/articles (Host: jsonplaceholder.typicode.com)
[2025-05-29T00:14:01.587Z] [PROXY_RES] Received from target for GET /articles - Status: 404
[2025-05-29T00:14:59.890Z] [PROXY_REQ] Forwarding GET /todos to: https://172.64.32.182/todos (Host: jsonplaceholder.typicode.com)
[2025-05-29T00:15:00.522Z] [PROXY_RES] Received from target for GET /todos - Status: 304
ログから各リクエストの処理結果を確認できます。
- https://jsonplaceholder.typicode.com/users へのリクエスト:
- ログ: GET /users (handled by routes/users.js)
- これは事前にモックとして定義したエンドポイントです。リクエストはローカルのモックサーバーで直接処理され、routes/users.js 経由で定義されたモックデータが返されます。
- https://jsonplaceholder.typicode.com/articles へのリクエスト:
- ログ: [PROXY_REQ] Forwarding GET /articles to: https://172.64.32.182/articles (Host: jsonplaceholder.typicode.com)
- ログ: [PROXY_RES] Received from target for GET /articles - Status: 404
- このエンドポイントはモックとして定義されていないため、リクエストは hosts ファイルの設定によってローカルサーバーに届いた後、さらに外部の実際のAPIサーバー(IPアドレス 172.64.32.182)へプロキシ転送されました。転送先のパス articles が存在しないため、404 Not Found のステータスコードが返されています。
- https://jsonplaceholder.typicode.com/todos へのリクエスト:
- ログ: [PROXY_REQ] Forwarding GET /todos to: https://172.64.32.182/todos (Host: jsonplaceholder.typicode.com)
- ログ: [PROXY_RES] Received from target for GET /todos - Status: 304
- このエンドポイントもモック定義されていないため、外部の実際のAPIサーバー(IPアドレス 172.64.32.182)へプロキシ転送されました。今回はパス todos が存在し、サーバーからの応答として 304 Not Modified のステータスコードが返されています。これは、コンテンツが変更されていない場合にサーバーが返す一般的なHTTPステータスです。
このように、モックサーバーは定義済みのエンドポイントにはモックレスポンスを、それ以外のエンドポイントには指定されたバックエンドサーバーへの透過的なプロキシとして機能していることが確認できました。
6. アプリケーションからの利用時の注意
これまでの手順で構築したHTTPSモックサーバーは、Webブラウザからは問題なく動作するはずです。しかし、iOSシミュレータ からこのモックサーバーを利用しようとすると、以下のようなSSL関連のエラーが発生する場合があります。
error [-1200] Error Domain=NSURLErrorDomain Code=-1200 "SSLエラーが起きたため、サーバへのセキュリティ保護された接続を確立できません。
このエラーは、iOSアプリが、あなたのNode.jsモックサーバーが提示するSSL証明書を信頼できないと判断した場合に発生します。これは、mkcert で作成した証明書が「自己署名証明書」であり、iOSの標準的な信頼チェーンに含まれていないためです。
この問題を解決するには、mkcertが作成したルートCA(認証局)証明書を、iOSシミュレータに「信頼できる」証明書として明示的にインストールする必要があります。これにより、ローカルCAが署名したすべての証明書がシミュレータ上で信頼されるようになります。
以下に、iOSシミュレータにルートCA証明書を信頼させる手順を示します。
mkcertのルートCA証明書(rootCA.pem)の場所を確認する
ルートCA証明書のパスは以下のコマンドで確認できます。
mkcert -CAROOT
このコマンドの出力例:/Users/myuser/Library/Application Support/mkcert
ルートCA証明書ファイルを開く
上記で確認したパスを元に、以下のコマンドを実行して rootCA.pem ファイルが保存されているディレクトリを Finder で開きます。
open "$(mkcert -CAROOT)"
rootCA.pem を iOSシミュレータにドラッグ&ドロップする
開いたディレクトリ内にある rootCA.pem ファイルを見つけ、実行中のiOSシミュレータの画面上にドラッグ&ドロップします。
証明書の信頼設定を有効にする
証明書をシステムレベルで信頼する設定を行います。
- iOSシミュレータの 「設定」アプリ を開きます。
- 「一般」 をタップします。
- 「情報」 をタップします。
- 一番下までスクロールし、「証明書信頼設定」 をタップします。
- 「ルート証明書を全面的に信頼」 セクションに、インストールした mkcert のルートCA証明書(例: mkcert myuser@192-168-... )が表示されます。その横のスイッチを オン に切り替えます。
- 確認のダイアログが表示されたら 「続ける」 をタップします。

これで、SSLエラーは解消するはずです。
まとめ
この記事では、APIが未実装であっても開発の進捗を止めないためのモックサーバー活用術を、段階的な3つのレベルに分けて解説しました。
まずは、最も基本的なアプローチとして、Express を使ってローカルにモックサーバーを立ち上げ、特定のエンドポイントにモックデータを返す方法を学びました。次に、モックサーバーにプロキシ機能を追加することで、未実装のAPIだけでなく、モック化しないAPIへのリクエストも透過的に処理できるようになりました。そして最終段階では、アプリケーションのコードに一切手を加えることなく、特定のドメインへのリクエストをローカルのモックサーバーへ向ける「透過的なリダイレクト」を実現しました。
APIの状況に振り回されることなく、常に「開発を止めない」選択肢を持つことは、現代のアジャイルな開発プロセスにおいて非常に重要です。「今日の進捗は?」という問いに自信を持って答えられるように、開発環境にモックサーバーを備えることは、開発者として強力な武器になります。
今回解説した LEVEL-3 までの実装をまとめたリポジトリをGitHubで公開しています。ぜひ皆さんの開発環境に合わせてカスタマイズし、ご活用ください。
- Uncoming-API
← スターをいただけると嬉しいです(圧)
この記事が、皆さんの開発効率向上の一助となれば幸いです。
最後までお読みいただき、ありがとうございました。
この記事を書いた人
Advent Calendar!
Advent Calendar 2024
What is BEMA!?
Be Engineer, More Agile