簡単なAPIをちょっとした工夫で完成度を高める話

[PHPで簡単なAPIを作った話]という記事を読みまして、自分ならどう改善していくかを考えました。

https://qiita.com/moons/items/436fcbc63330c721d6fe

## Step 1. 整形する

`=` `+` `.` などの演算子の周りを空白で空けてあげます。これだけで何か見ためが綺麗めになります。

```php
<?php

$front = $_GET['front'];
$back = $_GET['back'];
$result = $front + $back;
$response['result'] = $result;
$response['status'] = "true";
$response['formula'] = $front . '+' . $back;

print json_encode($response, JSON_UNESCAPED_UNICODE);
```

私はPHPを以下のように起動しました。

```
% php -S localhost:3939 easyapi.php
```

この状態で http://localhost:3939/ にアクセスするとAPIが動作するという寸法です。

## Step 2. 入力値を安全に受け取る

さて、この状態で何のクエリパラメータも渡さずにAPIにアクセスすると、このような表示[^1]になってしまいます。

> ![スクリーンショット 2021-03-02 21.53.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4121/267b64aa-3e4a-99ec-cca7-ce7ac3e95524.png)

[^1]: PHPに詳しいかたはすぐに気付くでしょうが、最新のPHP 8.0ではなく7.4とかいう古臭いバージョンを使っています

さあ、このようなエラー表示が出力に混じってしまうようではAPIとして役目を果たせません。実際にJSONとしてデコードできませんからね。

このような場合にどうするかは2通りの考えがあります。

 * **A**: 値が渡されなかった場合は、**0が渡された**ということにして計算を続ける
 * **B**: 値が渡されなかった場合は、計算できないのでエラーを返す

**A**パターンの路線でいくならば、以下のように `?? 0` というものを付け足すだけです。

```php
<?php

$front = $_GET['front'] ?? 0;
$back = $_GET['back'] ?? 0;
// 以下は同じ
```

これは[null合体演算子](https://www.php.net/manual/ja/migration70.new-features.php#migration70.new-features.null-coalesce-op)という、なんだかかっちょいい名前のPHPの機能です。

```php
// ↓ わざと長く書くと
if (isset($_GET['front'])) {
    $front = $_GET['front'];
} else {
    $front = 0;
}

// ↓ ちょっと短く書くと
$front = isset($_GET['front']) ? $_GET['front'] : 0;
```

ということで、 `$front = $_GET['front'] ?? 0` と書くことで、すっきり書けるようになるのでした。

これでめでたしなのでしょうか。……いいえ、 *もっと悪いこと* ができます。

 * `?front=1&back=2`: 正常なパターン
 * `?front=&back=`: 空文字列を入力 = 異常
 * `?front=a&back=b`: アルファベットの文字を入力 = 異常
 * `?front[]=1&back=2`: 整数が入った配列と整数を入力 = 異常
 * `?front[]=1&back[]=2`: 整数が入った配列と整数を入力 = 異常? 正常?

以上のようなクエリパラメータを入力すると、たとえば以下のような表示になります。

> ![スクリーンショット 2021-03-02 22.07.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4121/ee7cd95e-815d-a475-27d7-be9759a5c34e.png)

これではやはりAPIとしての仕事をまっとうできるとは言えませんよね。

APIは「値が渡されない」だけではなく、「意図しない形式の値が渡される」ことを必ず考えなければいけないのです。

それは特別なことではなく、靴を脱いだら揃えたり、用を足したら手を洗うくらい当たり前のことです。 ~~別に洗わなくたって氏にはしない？ あ、そう…~~

というわけで、今後は**B**パターンで入力値検証に合格しなければ計算を進めない方針で進めます。

## Step 3. 入力値を型安全に受け取る

https://qiita.com/mpyw/items/2f9955db1c02eeef43ea

@mpywの[$_GET, $_POSTなどを受け取る際の処理 - Qiita](https://qiita.com/mpyw/items/2f9955db1c02eeef43ea)を読んできてください。読みましたね？ おめでとうございます。これであなたもPHPマスターです。

復習しましょう。`filter_input()`または`filter_var()`と検証フィルタを活用するのです。

 * [PHP: filter_input - Manual](https://www.php.net/filter_input)
 * [PHP: filter_var - Manual](https://www.php.net/manual/ja/function.filter-var.php)
 * [PHP: 検証フィルタ - Manual](https://www.php.net/manual/ja/filter.filters.validate.php)

```php
<?php

$front = filter_input(INPUT_GET, 'front', FILTER_VALIDATE_INT);
$back = filter_input(INPUT_GET, 'back', FILTER_VALIDATE_INT);

if ($front === false || $front === null || $back === false || $back === null) {
    $response = [
        'result' => null,
        'status' => false,
        'formula' => '',
    ];
} else {
    $result = $front + $back;
    $response['result'] = $result;
    $response['status'] = "true";
    $response['formula'] = $front . '+' . $back;
}

print json_encode($response, JSON_UNESCAPED_UNICODE);
```

`FILTER_VALIDATE_INT`は入力値が整数かどうかを検証するフィルタです。もし小数として受け取りたければ`FILTER_VALIDATE_FLOAT`を使います。

`$_GET['front']`のように値を取り出したときは文字列型(string)なのでしたが、`filter_input(INPUT_GET, 'front', FILTER_VALIDATE_INT)`のように取り出すと整数型(int)に、`filter_input(INPUT_GET, 'front', FILTER_VALIDATE_FLOAT)`のように取り出すと浮動小数点型(float)に値がキャストされるのでした。とても便利ですね。

不正な値が渡された場合は`false`に、そもそも値が渡されなかった場合は`null`になるのでした。よって、このような判定になるのです。`$front === false || $front === null || $back === false || $back === null`は冗長ですが、`empty($front) || empty($back)`などと書くと`0`も弾かれてしまいます。

```php
$is_unexpected_value = fn($v) => in_array($v, [false, null], true);
if ($is_unexpected_value($front) || $is_unexpected_value($back)) {
    // ...
```

もっとも、今回の場合は`int`, `false`, `null`の3バターンしかないので、以下のように書いた方が簡単でした。

```php
if (!is_int($front) || !is_int($back)) {
    // ...
```

この条件は以下のように書き換えても構いません。

```php
if (!(is_int($front) && is_int($back))) {
```

これは[ド・モルガンの法則 - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%89%E3%83%BB%E3%83%A2%E3%83%AB%E3%82%AC%E3%83%B3%E3%81%AE%E6%B3%95%E5%89%87)によるものです。私は算数がとても苦手ですが名前がおもしろいのでよく覚えています。

## Step 4. 適切なHTTPヘッダを設定する

PHPはデフォルトでは`Content-type: text/html; charset=UTF-8`として取り扱うので、HTML以外のデータ形式を出力する場合は[`Content-Type`](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type)を適切にセットする必要があります。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type

```php
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
echo json_encode($response, JSON_UNESCAPED_UNICODE);
```

 * [X-Content-Type-Options - HTTP | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/X-Content-Type-Options)
 * [機密情報を含むJSONには X-Content-Type-Options: nosniff をつけるべき - 葉っぱ日記](http://hasegawa.hatenablog.com/entry/20130517/p1)
 * [X-Content-Type-Options: nosniff はIE以外にも必要 – yohgaki's blog](https://blog.ohgaki.net/x-content-type-options-nosniff-is-required-by-other-than-ie)

これはWebブラウザの推測による予期しない振舞いを避けるためのものです。

細かいようですが、これらはWebからアクセスされうるAPIでは基本的に設定すべきものです。なぜ付ける必要があるのかわからなければ、必ず付けてください。

## Step 5. 細かいところもこだわっていこう

神は細部に宿ります。

```php
<?php

declare(strict_types=1);

error_reporting(E_ALL);

$front = filter_input(INPUT_GET, 'front', FILTER_VALIDATE_INT);
$back = filter_input(INPUT_GET, 'back', FILTER_VALIDATE_INT);

$response = [
    'result' => null,
    'status' => false,
    'formula' => '',
];

if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'])) {
    http_response_code(405);
} elseif (!is_int($front) || !is_int($back)) {
    http_response_code(400);
} else {
    $response = [
        'result' => $front + $back,
        'status' => true,
        'formula' => "{$front} + {$back}",
    ];
}

header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
echo json_encode($response, JSON_UNESCAPED_UNICODE);
```

 * `declare(strict_types=1);`
    * これを付けると関数呼び出し時に引数の型が間違ってると判定が厳しくなります
    * ただし今回はPHPの内部関数しか使ってないので、あまり意味がないんですけど
 * `error_reporting(E_ALL)`
    * これを付けるとNoticeなどのエラーも見逃されなくなります
    * 今回はエラーが発生しないように作ってありますが、細かいエラーを握り潰さないという気概を示しておきます
 * HTTPメソッドは`GET`と`HEAD`以外は許可せず`405`を返すことにします
    * [405 Method Not Allowed - HTTP | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Status/405)
    * 一般的なWeb APIはHTTPメソッドが固定されています
    * フレームワークを使わないPHPではHTTPメソッドの判定が甘くなりがちです
    * `HEAD`メソッドをサポートする必要があるかといえばないのですが、気分です
 * 意図したパラメータが渡されなかった場合は`400`を返すことにします
    * [400 Bad Request - HTTP | MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Status/400)

## まとめ

今回は外部ライブラリやフレームワークなどを使わずにPHPで安全なAPIを実装するために気にするべきことをまとめました。これらは普段フレームワークを使って開発していたとしても、PHPを使っていれば基本的に気にするべき事柄です。残念なことにフレームワークを使うだけで即座に安全になるといった性格のものではありません。(フレームワークの機能をきちんと選択すれば避けられるかもしれませんが)

以下は読者への課題とします。

 * 好きな個数の数を計算するAPIを実装する
 * 好きな演算の種類を選んで実行時エラーを起こさずに計算できるAPIを実装する
 * 使い慣れているフレームワークでAPIを実装してみて不正な入力にどう対応できるか確認する

私からは以上です。

## おまけ： 内部関数と厳密な型検査

今回は`pow()`を再実装することで検証してみましょう。

https://www.php.net/pow

```php
<?php

declare(strict_types=1);

var_dump(pow('2', '10'));
var_dump(mypow('2', '10'));

function mypow(int|float $base, int|float $exp): int|float
{
    return $base ** $exp;
}
```

動作確認してみましょう。

https://3v4l.org/Ei4aZ

`pow()`と`mypow()`は`declare(strict_types=1);`を外すと同じになります。

[PHPで簡単なAPIを作った話]: https://qiita.com/moons/items/436fcbc63330c721d6fe