PHPだけでRESTAPIを再現してみた

#動機
最近はReactなどフロントばっかり触っているのですが、データ処理などのバックエンドを書くのは正直面倒くさいです。
普通であればLaravelのRESTAPI用のライブラリを使うのですが、Laravelの勉強はしたくないので、生のPHPだけでRESTAPIを再現してみることにしました。

なお、RESTAPIっぽいルーティングを再現するために以下のサイトを参考にしました。

https://qiita.com/naga3/items/030f757ed413515551db

#要求
APIの設計については、次の要件を満たすこととします。なお、以下のサイトを参考にしました。

- HTTPメソッド(GET,POST,PUT,DELETE)で操作を行う。
- バージョンをURLに含める。
- 作成・更新後は変更後のリソースの情報を返す。
- リクエスト・レスポンスのボディはJSONである。
- 適切なHTTPステータスコードを用いる。

https://qiita.com/mserizawa/items/b833e407d89abd21ee72

#実行環境
実行環境は以下の通りです。
- PHP 7.4.3
- Apache 2.4.41
- MySQL 8.0.23

###データベース(MySQL)の設定
今回は、`sample_database`のデータベースに、`sample_table`のテーブルを作成しました。またテーブルの中に、`id`,`name`,`age`のカラムを設定しています。

```sql
CREATE TABLE `sample_table` (
  `id` int NOT NULL,
  `name` varchar(32) NOT NULL,
  `age` tinyint UNSIGNED DEFAULT NULL
);
```

#ディレクトリ構成
```
root/
　├ config/
　│　└ database.php
　├ v1/
　│　├ controllers/
　│　│　└ sample.php
　│　├ index.php
　│　└ .htaccess
　├ v2/
　│　└...
　└ DB.php
```

##データベースへの接続
データベースへの接続は、`/DB.php`,`/config/database.php`で行っています。

```/DB.php
<?php

include(__DIR__ . "/config/database.php");

class DB
{
    function pdo()
    {
        global $setting;
        try{
            $driver_option = [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_EMULATE_PREPARES => false,
            ];
            $pdo = new PDO($setting["dsn"],$setting["user"],$setting["password"],$driver_option);
        }catch(PDOException $error){
            header("Content-Type: application/json; charset=utf-8", true, 500);
            echo json_encode(["error" => ["type" => "server_error","message"=>$error->getMessage()]]);
            die();
        }
        return $pdo;
    }
}
```

```/config/database.php
<?php
$setting = [
    "dsn" => "mysql:dbname=sample_database;host=localhost;charset=utf8mb4",
    "user" => "user",
    "password" => "password"
];
```

これらのファイルは公開する必要がないので、公開フォルダの外側に設置するのが望ましいでしょう。
特に、`/config/database.php`のファイルはデータベースの接続情報が入っているので、`.gitignore`に追加するなど、外部流出には気をつけましょう。

##サーバ(Apache)の設定
```/v1/.htaccess
RewriteEngine On
RewriteRule ^ index.php [L]
```
これで`/v1`直下へのアクセスはすべて`/v1/index.php`に飛ばされます。

##バージョン管理
`/v1`,`/v2`,...と増やしていくことで、RESTAPIのバージョン管理ができます。

##ルーティング
URLに応じて適切なファイルで処理をするためのルーティングです。

```/v1/index.php
<?php

include(__DIR__ . "/../DB.php");

preg_match('|'.dirname($_SERVER["SCRIPT_NAME"]).'/([\w%/]*)|', $_SERVER["REQUEST_URI"], $matches);
$paths = explode('/',$matches[1]);
$file = array_shift($paths);

$file_path = './controllers/'.$file.'.php';
if(file_exists($file_path)){
    include($file_path);
    $class_name = ucfirst($file)."Controller";
    $method_name = strtolower($_SERVER["REQUEST_METHOD"]);
    $object = new $class_name();
    $response = json_encode($object->$method_name(...$paths));
    $response_code = $object->code ?? 200;
    // header("Access-Control-Allow-Origin: *");
    header("Content-Type: application/json; charset=utf-8", true, $response_code);
    echo $response;
}else{
    header("HTTP/1.1 404 Not Found");
    exit;
}
```

###ルーティング：解説

まずURLをわかりやすいように配列に変換します。
URLが`xxx.com/yy/v1/sample/arg1/arg2/...`の場合、`$paths`には、`sample`以降の値が`/`ごとに区切られて配列に格納されます。3行目で配列がshiftされるので、`$file`は`sample`、`$paths`は`arg1`以降の配列となります。

```php
preg_match('|'.dirname($_SERVER["SCRIPT_NAME"]).'/([\w%/]*)|', $_SERVER["REQUEST_URI"], $matches);
$paths = explode('/',$matches[1]);
$file = array_shift($paths);
```

次に処理を行うファイルを決定します。
`$file`が`sample`の場合は、`/controllers/sample.php`のファイルで処理が行われます。

```php
$file_path = './controllers/'.$file.'.php';
if(file_exists($file_path)){
    (中略)
}else{ //ファイルが存在しなければエラー(404)
    header("HTTP/1.1 404 Not Found");
    exit;
}
```

次に実際にファイルを読み込みます。
ファイル内のクラス名`class_name`は、`(ファイル名の先頭を大文字にした単語)+Controller`になります。例えば`$file`が`sample`の場合は、`SampleController`となります。
`method_name`にはHTTPメソッド(get,post,put,delete)などが入ります。

```php
    include($file_path);
    $class_name = ucfirst($file)."Controller";
    $method_name = strtolower($_SERVER["REQUEST_METHOD"]);
```

次にクラスのインスタンスを作成し、そのメソッドに値を渡し、返り値を表示するようにします。
`$object->$method_name(...$paths)`で、クラス内に作成したHTTPメソッドに対応するメソッド(getなど)が引数`$paths`で実行されます。
また、プロパティ`$code`の値がHTTPステータスコードになります。

```php
    $object = new $class_name();
    $response = json_encode($object->$method_name(...$paths));
    $response_code = $object->code ?? 200;
    // header("Access-Control-Allow-Origin: *");  // CORSでOriginエラーになる場合は、コメントを外す
    header("Content-Type: application/json; charset=utf-8", true, $response_code);
    echo $response;
```

##コントローラ

このファイルで実際に処理を行います。
ファイル名とクラス名には、命名規則があります。(前項参照)

```/v1/controllers/sample.php
<?php

class SampleController
{
    public $code = 200;
    public $url;
    public $request_body;

    function __construct()
    {
        $this->url = (empty($_SERVER['HTTPS']) ? 'http://' : 'https://').$_SERVER['HTTP_HOST'].mb_substr($_SERVER['SCRIPT_NAME'],0,-9).basename(__FILE__, ".php")."/";
        $this->request_body = json_decode(mb_convert_encoding(file_get_contents('php://input'),"UTF8","ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN"),true);
    }

    public function get($id=null):array
    {
        $db = new DB();
        echo $_GET["data"];
        if($this->is_set($id)){
            return $this->getById($db, $id);
        }else{
            return $this->getAll($db);
        }
    }

    private function getById($db,$id):array
    {
        $sql = "SELECT * FROM sample_table WHERE id = :id";
        $sth = $db->pdo()->prepare($sql);
        $sth->bindValue(":id",$id);
        $res = $sth->execute();
        if($res){
            $data = $sth->fetch(PDO::FETCH_ASSOC);
            if(!empty($data)){
                return $data;
            }else{
                $this->code = 404;
                return ["error" => [
                    "type" => "not_in_sample"
                ]];
            }
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
    }
    private function getAll($db):array
    {
        $sql = "SELECT * FROM sample_table";
        $sth = $db->pdo()->prepare($sql);
        $res = $sth->execute();
        if($res){
            return $sth->fetchAll(PDO::FETCH_ASSOC);
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
    }

    public function post():array
    {
        $post = $this->request_body;
        if(!array_key_exists("id",$post) || !array_key_exists("name",$post) || !array_key_exists("age",$post)){
            $this->code = 400;
            return ["error" => [
                "type" => "invalid_param"
            ]];
        }
        $db = new DB();
        $pdo = $db->pdo();
        $sql = "INSERT INTO sample_table (id, name, age) VALUES (:id, :name, :age)";
        $sth = $pdo->prepare($sql);
        $sth->bindValue(":id",$post["id"]);
        $sth->bindValue(":name",$post["name"]);
        $sth->bindValue(":age",$post["age"]);
        $res = $sth->execute();
        $id = $pdo->lastInsertId();
        if($res){
            $this->code = 201;
            header("Location: ".$this->url.$id);
            return [];
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
        
    }

    public function put($id=null):array
    {
        if(!$this->is_set($id)){
            $this->code = 400;
            return ["error" => [
                "type" => "invalid_url"
            ]];
        }
        $original_data = $this->get($id);
        if(empty($original_data)){
            $this->code = 404;
            return ["error" => [
                "type" => "not_in_sample"
            ]];
        }
        $put = array_merge($original_data, $this->request_body);
        $db = new DB();
        $sql = "UPDATE sample_table SET name=:name,age=:age WHERE id=:id";
        $sth = $db->pdo()->prepare($sql);
        $sth->bindValue(":id",$id);
        $sth->bindValue(":name",$put["name"]);
        $sth->bindValue(":age",$put["age"]);
        $res = $sth->execute();
        if($res){
            return [$this->get($id)];
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
        
    }

    public function delete($id=null):array
    {
        if(!$this->is_set($id)){
            $this->code = 400;
            return ["error" => [
                "type" => "invalid_url"
            ]];
        }
        $db = new DB();
        $sql = "DELETE FROM sample WHERE id = :id";
        $sth = $db->pdo()->prepare($sql);
        $sth->bindValue(":id",$id);
        $res = $sth->execute();
        if($res){
            $this->code = 204;
            return [];
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
    }

    public function options():array
    {
        header("Access-Control-Allow-Methods: OPTIONS,GET,HEAD,POST,PUT,DELETE");
        header("Access-Control-Allow-Headers: Content-Type");
        return [];
    }

    private function is_set($value):bool
    {
        return !(is_null($value) || $value === "");
    }
}
```

###コントローラ：解説

**コントローラ内のメソッドの返り値は連想配列(PHP)であることが必要です。**

#### __construct

- `$code`：HTTPステータスコード
- `$url`：このリクエストのURL(`/v1/sample/arg1/arg2/...`の場合は、`/v1/sample/`まで)
- `$request_body`：リクエストボディ(JSONに対応)

```php
    public $code = 200;
    public $url;
    public $request_body;

    function __construct()
    {
        $this->url = (empty($_SERVER['HTTPS']) ? 'http://' : 'https://').$_SERVER['HTTP_HOST'].mb_substr($_SERVER['SCRIPT_NAME'],0,-9).basename(__FILE__, ".php")."/";
        $this->request_body = json_decode(mb_convert_encoding(file_get_contents('php://input'),"UTF8","ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN"),true);
    }
```

※万が一、リクエストボディをhtmlのinputタグで実装する場合(`application/x-www-form-urlencoded`)は、以下の記事を参考にしてJSONで送るようにするか、`parse_str(file_get_contents('php://input'),$request_body);`のように直接展開してください。

https://teratail.com/questions/238362

#### get

getメソッド：データの取得
なお、引数には`$paths`の値が展開されて入ります。よって`/v1/sample/:id/:key`の形である場合、`get($id=null,$key=null)`とします。

```php
    public function get($id=null):array
    {
        $db = new DB();
        if($this->is_set($id)){ //(null or 空白)でないことを判定
            return $this->getById($db, $id);
        }else{
            return $this->getAll($db);
        }
    }

    private function getById($db,$id):array
    {
        (中略) //一部取得する
    }
    private function getAll($db):array
    {
        (中略) //全件取得する
    }

```

#### post

postメソッド：データの追加
必要なキー(項目)がリクエストボディにあるかどうかを`array_key_exists`でチェックします。

```php
    public function post():array
    {
        $post = $this->request_body;
        if(!array_key_exists("id",$post) || !array_key_exists("name",$post)){  //$postに必要なキーが存在することをチェック
            $this->code = 400;
            return ["error" => [
                "type" => "invalid_param"
            ]];
        }
        $db = new DB();
        $pdo = $db->pdo();
        $sql = "INSERT INTO sample_table (id, name, age) VALUES (:id, :name, :age)";
        $sth = $pdo->prepare($sql);
        $sth->bindValue(":id",$post["id"]);
        $sth->bindValue(":name",$post["name"]);
        $sth->bindValue(":age",$post["age"]);
        $res = $sth->execute();
        // $id = $pdo->lastInsertId();  // IDをAutoIncrementで設定する場合は、これでidを取得
        if($res){
            $this->code = 201;  //新しいリソースが生成されたことを示す
            header("Location: ".$this->url.$post["id"]);  //生成されたリソースを取得するURL
            return [];
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
    }
```

※リクエストボディのキーチェックを行っていないもの(今回では`age`が該当)は、以下のいずれかを満たす必要があります。

- データベースの該当するカラムで、`NOT NULL制約`が外れている
- `bindValue(":age",$post["age"]);` → `bindValue(":age",$post["age"]??0);`のようにNULL演算子を用いてデフォルト値を設定する

####put

putメソッド：データの上書き


```php
    public function put($id=null):array
    {
        if(!$this->is_set($id)){ //(null or 空白)であればエラー(400)
            $this->code = 400;
            return ["error" => [
                "type" => "invalid_url"
            ]];
        }
        $original_data = $this->get($id);
        if(empty($original_data)){ //該当するデータがなければエラー(404)
            $this->code = 404;
            return ["error" => [
                "type" => "not_in_sample"
            ]];
        }
        $put = array_merge($original_data, $this->request_body); //元のデータにリクエストボディの値を上書き
        $db = new DB();
        $sql = "UPDATE sample_table SET name=:name,age=:age WHERE id=:id";
        $sth = $db->pdo()->prepare($sql);
        $sth->bindValue(":id",$id);
        $sth->bindValue(":name",$put["name"]);
        $sth->bindValue(":age",$put["age"]);
        $res = $sth->execute();
        if($res){
            return [$this->get($id)];
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
    }
```

####delete

deleteメソッド：データの削除

```php
    public function delete($id=null):array
    {
        if(!$this->is_set($id)){ //(null or 空白)であればエラー(400)
            $this->code = 400;
            return ["error" => [
                "type" => "invalid_url"
            ]];
        }
        $db = new DB();
        $sql = "DELETE FROM sample WHERE id = :id";
        $sth = $db->pdo()->prepare($sql);
        $sth->bindValue(":id",$id);
        $res = $sth->execute();
        if($res){
            $this->code = 204;  //処理は成功したが、返すデータがないことを示す
            return [];
        }else{
            $this->code = 500;
            return ["error" => [
                "type" => "fatal_error"
            ]];
        }
    }
```

####options

CORSを利用したリクエストにおいて、[単純なリクエスト](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#simple_requests)以外のものは、プリフライトリクエストと呼ばれます。プリフライトリクエストでは、はじめに`OPTIONS`メソッドにより、そのリソースが利用可能かどうかを判断します。

```php
    public function options():array
    {
        header("Access-Control-Allow-Methods: OPTIONS,GET,POST,PUT,DELETE");
        header("Access-Control-Allow-Headers: Content-Type");
        return [];
    }
```

使用しないHTTPメソッドは、`Access-Control-Allow-Methods`から削除するようにしてください。

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

#終わりに

今回使用したソースコードは、GitHubにあげています。

https://github.com/m-guchi/php_restapi_templete