34
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-03

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

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

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

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

#実行環境
実行環境は以下の通りです。

  • PHP 7.4.3
  • Apache 2.4.41
  • MySQL 8.0.23

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

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されるので、$filesample$pathsarg1以降の配列となります。

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

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

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

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

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

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

    $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に対応)
    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);のように直接展開してください。

get

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

    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でチェックします。

    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メソッド:データの上書き

    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メソッド:データの削除

    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を利用したリクエストにおいて、単純なリクエスト以外のものは、プリフライトリクエストと呼ばれます。プリフライトリクエストでは、はじめにOPTIONSメソッドにより、そのリソースが利用可能かどうかを判断します。

    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から削除するようにしてください。

#終わりに

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

34
31
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?