#動機
最近は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
で行っています。
<?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;
}
}
<?php
$setting = [
"dsn" => "mysql:dbname=sample_database;host=localhost;charset=utf8mb4",
"user" => "user",
"password" => "password"
];
これらのファイルは公開する必要がないので、公開フォルダの外側に設置するのが望ましいでしょう。
特に、/config/database.php
のファイルはデータベースの接続情報が入っているので、.gitignore
に追加するなど、外部流出には気をつけましょう。
##サーバ(Apache)の設定
RewriteEngine On
RewriteRule ^ index.php [L]
これで/v1
直下へのアクセスはすべて/v1/index.php
に飛ばされます。
##バージョン管理
/v1
,/v2
,...と増やしていくことで、RESTAPIのバージョン管理ができます。
##ルーティング
URLに応じて適切なファイルで処理をするためのルーティングです。
<?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
以降の配列となります。
preg_match('|'.dirname($_SERVER["SCRIPT_NAME"]).'/([\w%/]*)|', $_SERVER["REQUEST_URI"], $matches);
$paths = explode('/',$matches[1]);
$file = array_shift($paths);
次に処理を行うファイルを決定します。
$file
がsample
の場合は、/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
になります。例えば$file
がsample
の場合は、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;
##コントローラ
このファイルで実際に処理を行います。
ファイル名とクラス名には、命名規則があります。(前項参照)
<?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にあげています。