PHP で POST API を実装したらリクエストデータが null になった話

## TL;DR

リクエストデータの JSON プロパティの最後に `,` がついていたので `json_decode()` が `null` を返していた。

結果的によくあるミスが原因でした。自戒を込めてまとめてみましたが、誰かの参考になれば幸いです。

## 背景

PHP(WordPress) に REST API を実装しようと思い、まずはシンプルに受け取った JSON データをそのまま返却しようと実装してみました。
動作確認したら、リクエストデータが返却されず1時間くらい悪戦苦闘したときのメモです。

## 実行環境

- PHP: 8.2.28
- VSCode
- REST Client

<details>
<summary>details</summary>

- macOS: 15.3.2
- docker: 27.5.1
</details>

## とりあえず作ったもの

後述の記事を参考にさせていただきました。

WordPress の公開ディレクトリを基準に次のような構成でファイルを作成しました。

```txt
.
├── api/
│   └── v1/
│       └── post-sample.php
├── configs/
│   └── configs.php
└── utils/
    └── logger.php
```

```php:/api/v1/post-sample.php
<?php
require __DIR__ . '/../../configs/configs.php';
require __DIR__ . '/../../utils/logger.php';

// ログ用の定数
const API_NAME = 'post-sample';
const LOG_PREFIX = 'api/v1/' . API_NAME . '/ - ';

// ロガーを生成
$logger = Logger::getInstance();

// 開始ログを出力
$logger->debug(LOG_PREFIX . 'Start.');

// レスポンスヘッダを設定
header("Content-Type: application/json; charset=UTF-8");
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');

// リクエストデータを文字列として取得
// NOTE: ここらへんは ChatGPT のサジェストを採用
$request_body = mb_convert_encoding(
    file_get_contents('php://input'),
    "UTF8",
    "ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN"
);
// 文字列をJSON形式に変換
$request_json = json_decode($request_body, true);

// 中身を確認(なんやかんやあっていれたログ)
$logger->debug(LOG_PREFIX . 'Request method: ' . $_SERVER['REQUEST_METHOD']);
$logger->debug(LOG_PREFIX . 'Request headers: ' . json_encode(getallheaders()));
$logger->debug(LOG_PREFIX . 'Request body: ' . $request_body);
$logger->debug(LOG_PREFIX . 'Request JSON: ' . json_encode($request_json));

// TODO: API 処理を実装したい

// 結果を返却
$response = array(
    "status" => "success",
    "prop1" => $request_json["prop1"] ?? null,
    "prop2" => $request_json["prop2"] ?? null,
);

echo json_encode($response);
?>
```

<details>
<summary>補足: API 以外のファイル</summary>

```php:/configs/configs.php
<?php
class AppnConfigs
{
    /**
     * ログファイル出力フラグ
     * - true: ログファイル出力
     * - false: ログファイル出力しない
     * @var bool
     * @access public
     */
    public const IS_LOGFILE = true;

    /**
     * ログレベル
     * - 0: FATAL
     * - 1: ERROR
     * - 2: WARN
     * - 3: INFO
     * - 4: DEBUG
     * @var int
     * @access public
     */
    public const LOG_LEVEL = 4;

    /**
     * ログファイル出力ディレクトリ
     * @var string
     * @access public
     */
    public const LOGDIR_PATH = __DIR__ . '/../logs/';

    /**
     * ログファイル名
     * @var string
     * @access public
     */
    public const LOGFILE_NAME = 'console';

    /**
     * ログファイル最大サイズ（Byte）
     * @var int
     * @access public
     */
    public const LOGFILE_MAXSIZE = 10485760;

    /**
     * ログ保存期間（日）
     * @var int
     * @access public
     */
    public const LOGFILE_PERIOD = 30;
}
?>
```

```php:logger.php
<?php
/**
 * ログ
 */
class Logger
{

    /**
     * ログレベル: FATAL
     * @var int
     */
    public const LOG_LEVELFATAL = 0;
    /**
     * ログレベル: ERROR
     * @var int
     */
    public const LOG_LEVEL_ERROR = 1;
    /**
     * ログレベル: WARN
     * @var int
     */
    public const LOG_LEVEL_WARN = 2;
    /**
     * ログレベル: INFO
     * @var int
     */
    public const LOG_LEVEL_INFO = 3;
    /**
     * ログレベル: DEBUG
     * @var int
     */
    public const LOG_LEVEL_DEBUG = 4;

    private static $singleton;

    /**
     * シングルトンインスタンスを生成します
     * @return Logger
     * @access public
     * @static
     */
    public static function getInstance(): Logger
    {
        if (!isset(self::$singleton)) {
            self::$singleton = new Logger();
        }
        return self::$singleton;
    }

    /**
     * コンストラクタ
     */
    private function __construct()
    {
    }

    /**
     * FATALレベルのログ出力する
     * @param string $msg メッセージ
     */
    public function fatal($msg): void
    {
        if (self::LOG_LEVELFATAL <= AppConfigs::LOG_LEVEL) {
            $this->out('FATAL', $msg);
        }
    }

    /**
     * ERRORレベルのログ出力する
     * @param string $msg メッセージ
     */
    public function error($msg): void
    {
        if (self::LOG_LEVEL_ERROR <= AppConfigs::LOG_LEVEL) {
            $this->out('ERROR', $msg);
        }
    }

    /**
     * WARNレベルのログ出力する
     * @param string $msg メッセージ
     */
    public function warn($msg): void
    {
        if (self::LOG_LEVEL_WARN <= AppConfigs::LOG_LEVEL) {
            $this->out('WARN', $msg);
        }
    }

    /**
     * INFOレベルのログ出力する
     * @param string $msg メッセージ
     */
    public function info($msg): void
    {
        if (self::LOG_LEVEL_INFO <= AppConfigs::LOG_LEVEL) {
            $this->out('INFO', $msg);
        }
    }

    /**
     * DEBUGレベルのログ出力する
     * @param string $msg メッセージ
     */
    public function debug($msg): void
    {
        if (self::LOG_LEVEL_DEBUG <= AppConfigs::LOG_LEVEL) {
            $this->out('DEBUG', $msg);
        }
    }

    /**
     * ログ出力する
     * @param string $level ログレベル
     * @param string $msg メッセージ
     */
    private function out($level, $msg): void
    {
        if (!AppConfigs::IS_LOGFILE) {
            return;
        }

        $pid = getmypid();
        $time = $this->getTime();
        $logMessage = "{$time}\t[{$pid}]\t[{$level}]\t" . rtrim($msg) . "\n";
        $logFilePath = AppConfigs::LOGDIR_PATH . AppConfigs::LOGFILE_NAME . '.log';

        $result = file_put_contents($logFilePath, $logMessage, FILE_APPEND | LOCK_EX);
        if (!$result) {
            error_log('LogUtil::out error_log ERROR', 0);
            return;
        }

        if (AppConfigs::LOGFILE_MAXSIZE < filesize($logFilePath)) {
            $this->rotateLogFile($logFilePath);
            $this->deleteOldLogFiles();
        }
    }

    private function rotateLogFile(string $logFilePath): void
    {
        $oldPath = AppConfigs::LOGDIR_PATH . AppConfigs::LOGFILE_NAME . '_' . date('YmdHis');
        $oldLogFilePath = $oldPath . '.log';
        rename($logFilePath, $oldLogFilePath);

        $gz = gzopen($oldPath . '.gz', 'w9');
        if ($gz) {
            gzwrite($gz, file_get_contents($oldLogFilePath));
            $isClose = gzclose($gz);
            if ($isClose) {
                unlink($oldLogFilePath);
            } else {
                error_log("gzclose ERROR.", 0);
            }
        } else {
            error_log("gzopen ERROR.", 0);
        }
    }

    private function deleteOldLogFiles(): void
    {
        $retentionDate = new DateTime();
        $retentionDate->modify('-' . AppConfigs::LOGFILE_PERIOD . ' day');

        if ($dh = opendir(AppConfigs::LOGDIR_PATH)) {
            while (($fileName = readdir($dh)) !== false) {
                $pm = preg_match("/" . preg_quote(AppConfigs::LOGFILE_NAME) . "_(\d{14}).*\.gz/", $fileName, $matches);
                if ($pm === 1) {
                    $logCreatedDate = DateTime::createFromFormat('YmdHis', $matches[1]);
                    if ($logCreatedDate < $retentionDate) {
                        unlink(AppConfigs::LOGDIR_PATH . '/' . $fileName);
                    }
                }
            }
            closedir($dh);
        }
    }

    /**
     * 現在時刻を取得する
     * @return string 現在時刻
     */
    private function getTime(): string
    {
        $miTime = explode('.', microtime(true));
        $msec = str_pad(substr($miTime[1], 0, 3), 3, "0");
        $time = date('Y-m-d H:i:s', $miTime[0]) . '.' . $msec;
        return $time;
    }
}
```

</details>



クライアント側は REST Client で次のファイルを準備しました。

```txt:sample.http
POST http://localhost:18081/api/v1/post-sample.php
Content-Type: application/json;charset=utf-8
Accept: application/json

{
  "prop1": "This is prop1",
  "prop2": 123,
}

```

## 実行してみた結果

```http
HTTP/1.1 200 OK
Date: Mon, 07 Apr 2025 XX:XX:XX GMT
Server: Apache/2.4.62 (Debian)
X-Powered-By: PHP/8.2.28
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Content-Length: 46
Connection: close
Content-Type: application/json; charset=UTF-8

{
  "status": "success",
  "prop1": null,
  "prop2": null
}
```

あれ、prop が null になっている。。。

ちなみに、ログは下記のとおりです。

```log
2025-04-07 xx:xx:xx.xxx	[35]	[DEBUG]	api/v1/post-sample/ - Start.
2025-04-07 xx:xx:xx.xxx	[35]	[DEBUG]	api/v1/post-sample/ - Request method: POST
2025-04-07 xx:xx:xx.xxx	[35]	[DEBUG]	api/v1/post-sample/ - Request headers: {"user-agent":"vscode-restclient","content-type":"application\/json;charset=utf-8","accept":"application\/json","accept-encoding":"gzip, deflate","content-length":"47","Host":"localhost:18081","Connection":"close"}
2025-04-07 xx:xx:xx.xxx	[35]	[DEBUG]	api/v1/post-sample/ - Request body: {
  "prop1": "This is prop1",
  "prop2": 123,
}
2025-04-07 xx:xx:xx.xxx	[35]	[DEBUG]	api/v1/post-sample/ - Request JSON: null
```

どうやら、`$request_body` までは正しく取得できているものの、`json_decode()` が `null` を返しているらしい。

## 問題の原因

色々調べてみましたが結局わからず、苦し紛れに上記のようなログを出力することでなんとか `json_decode()` で失敗していると判明。

よくわからなくなったところで ChatGPT に聞いてみたら、`不正なJSONが含まれている(構文エラーなど)が原因です` と言われました。

そこで、リクエストデータを改めてよく見てみます。

```diff:sample.http
POST http://localhost:18081/api/v1/post-sample.php
Content-Type: application/json;charset=utf-8
Accept: application/json

{
  "prop1": "This is prop1",
- "prop2": 123,
+ "prop2": 123
}
```

最後の `,` が余計でした。。。

## 修正後の実行結果

```http
HTTP/1.1 200 OK
Date: Mon, 07 Apr 2025 xx:xx:xx GMT
Server: Apache/2.4.62 (Debian)
X-Powered-By: PHP/8.2.28
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Content-Length: 56
Connection: close
Content-Type: application/json; charset=UTF-8

{
  "status": "success",
  "prop1": "This is prop1",
  "prop2": 123
}
```

```log
2025-04-07 xx:xx:xx.xxx	[37]	[DEBUG]	api/v1/post-sample/ - Start.
2025-04-07 xx:xx:xx.xxx	[37]	[DEBUG]	api/v1/post-sample/ - Request method: POST
2025-04-07 xx:xx:xx.xxx	[37]	[DEBUG]	api/v1/post-sample/ - Request headers: {"user-agent":"vscode-restclient","content-type":"application\/json;charset=utf-8","accept":"application\/json","accept-encoding":"gzip, deflate","content-length":"46","Host":"localhost:18081","Connection":"close"}
2025-04-07 xx:xx:xx.xxx	[37]	[DEBUG]	api/v1/post-sample/ - Request body: {
  "prop1": "This is prop1",
  "prop2": 123
}
2025-04-07 xx:xx:xx.xxx	[37]	[DEBUG]	api/v1/post-sample/ - Request JSON: {"prop1":"This is prop1","prop2":123}
```

無事に実行できました。

## あとがき

PHP だけでなく、JavaScript や Python では、配列の最後に `,` を入れなくてもエラーにならないため、手癖で `,` を入れてしまったことが原因でした。

このようなエラーはひとりで作業しているとどうしても発生するミスであると思っています。
誰かに相談するとすぐに解決するような内容ですが、今は ChatGPT など AI に壁打ちして解消できるのがいいですね。

## 参考

https://qiita.com/usayamadausako/items/b87885f5af3fb43e9266

- 主にカスタマイズしたところ
   - ローカルにバインドするポートを修正

https://qiita.com/guchimina/items/9f351944ddaa33ba73b0

https://qiita.com/yun_bow/items/73688096d180203d9e9e

- 主にカスタマイズしたところ
   - `Fatal` レベルを追加
   - Excel でログ解析しやすいようにセパレータは `\t` に変更

https://qiita.com/hidepy/items/42220523cb2b3eb2c451

https://qiita.com/mgmgmogumi/items/61f0b896580d3e6db2bb