20210303のPHPに関する記事は15件です。

Visual Studio Code で PHP を 使うための初期設定をしてみた!!!

はじめに

ずっと愛用していた「Brackets」のサポートが9月1日に終了してしまうみたいです(´;ω;`)

Brackets を開いたときに「Visual Studio Code」に移行してね、と書いていたのでとってもシンプルで使いやすかったBrackets ともお別れします!!!

とりあえずPHPが使えればいいので、ささっさっとやってみました。

Visual Studio Codeのインストール

Windows版でやっていきます!!
Visual Studio Code ダウンロード

インストールしていきます!
aaa.jpg

一応アクションを追加するにチェックつけときます!
messageImage_1614775913055.jpg

インストール!messageImage_1614776094991.jpg

messageImage_1614776039726.jpg

設定

インストールできたら開いて
左側の上から5番目の四角のやつで拡張機能をインストールしていきます!

日本語にするのは
「Japanese Language Pack for Visual」
ってやつです!

messageImage_1614776793945.jpg
テキトーに調べてたくさん入れました!
1614776718169.jpg
1614776736613.jpg

次に

フォルダーを開くで使用したいフォルダを開きます。

次に、ファイル(F)→ユーザ設定→設定 で 「settings.json]を編集します。
messageImage_1614777210589.jpg

settings.jsonを


{
    "liveServer.settings.donotShowInfoMsg": true,
    "liveServer.settings.proxy": {
        "enable": true,
        "baseUri": "/",
        "proxyUri": "http://localhost/使用したいフォルダ名/"
},
    "json.schemas": [

    ],
    "editor.fontLigatures": null
}

と記述します!

デバック

次に、実行とデバックで
messageImage_1614777992831.jpg

PHPを指定すると、
launch.json
ができます。


{
    // IntelliSense を使用して利用可能な属性を学べます。
    // 既存の属性の説明をホバーして表示します。
    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for XDebug",
            "type": "php",
            "request": "launch",
            "port": 9000
        },
        {
            "name": "Launch currently open script",
            "type": "php",
            "request": "launch",
            "program": "${file}",
            "cwd": "${fileDirname}",
            "port": 9000
        }
    ]
}![1614778787613.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1153081/4b32f1bf-6f81-4c08-49c7-8286aabffe79.jpeg)


これでデバックもできます!

動かす!

最後にindex.htmlなどの右上にある青いマークを押して1614778456049.jpg

こんな感じでHTMLがでて・・・

1614778787613.jpg

アカウント登録HTMLにも飛べて・・・
1614778734731.jpg

確認PHPも動きました!!!!
1614778911475.jpg

最後に

ここもではできたのですがMySQLにどうやってつなげるのかがまだわかってないので調べ倒そうと思います。。。

「Brackets」今までありがとう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel 画像の投稿機能とシンボリックリンク

はじめに

Laravelにて簡単な画像付きレシピ投稿アプリを作成しています。
画像のアップロード機能を実装した際にシンボリックリンクという言葉を初めて知ったので、備忘録として記録しておくことにしました。

シンボリックリンクとは

Windouwsでいうところのショートカットのようなもの。
シンボリックリンクファイルを通して大元のフォルダを参照することができます。

やりたいこと

投稿フォームから画像を選択し送信ボタンを押すと一覧で表示されるという仕組み。

画像ファイルを選択し送信ボタンをクリック
image.png
一覧画面へ画像を表示
ここでレシピ投稿アプリなのに料理の写真ねーじゃん!とツッコまれる
image.png

画像の保存先

さて、コントローラーで受け取った画像ファイルは基本的に storage/app/public/image 内に保存させます。

RecipeController.php
public function create(Request $request){
        $recipe = new Recipe;
        $form = $request->all();

        //画像が送信されたら保存して $recipe->image_pathカラム にパスを保存する
        if (isset($form['image'])) {//変数に値が入っているかをチェック
            $path = $request->file('image')->store('image');// 画像をstorage/app/public/image配下に保存
            $recipe->image_path = basename($path);//パスからを取得したファイル名をimage_pathカラムに保存
        } else {
            $recipe->image_path = null;
        }

        unset($form['_token']);
        unset($form['image']);

        $recipe->fill($form)->save();
        return redirect('/home');
    }

ただ、実際にアプリケーションを起動するときに公開されるのはpublicディレクトリのみです。画像はstorage/app/public/imageに保存されているので画像データを持ってくることはできません。
そんなときにpublicディレクトリからstorage/app/public/imageへのショートカット機能の役割を果たすのががシンボリックリンクです。

シンボリックリンクの貼り方

artisanコマンドから簡単にできます。
まずpublicディレクトリへ移動します。

$ cd public

以下のコマンドを実行します。

$ php artisan storage:link

するとpublic内にstorageフォルダができます。(ショートカットが作成されるの方がわかりやすいかも)
矢印ができます。

image.png

public/imageと進んでいくと、storage/app/public/imageと同じ画像ファイルが存在していることがわかるかと思います。

ビューに画像を表示させる

画像表示部分のみ

home.blade.php
<img src="{{ asset('storage/image/' . $recipe->image_path) }}" width="100%" heigth="100%" alt="">

asset('ファイルパス')はpublicディレクトリのパスを返す関数です。
丁寧に書くと、app/public/storage/image/[画像のパス名]となるわけです。
さっきシンボリックリンクを貼ったおかげでこのフォルダパスを通すことができました。

終わり。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Class 'App\Providers\view' not found 解決方法

エラー:Class 'App\Providers\view' not found

参考書Laravel入門(p.101)の「クロージャ(無名関数)でコンポーザ処理を作る」で、サービスプロバイダを登録しビューコンポーザを利用する為に、/helloにアクセスしたところ、
スクリーンショット 2021-03-03 22.14.28.png

Class 'App\Providers\view' not found

エラーが発生!
このエラーは、viewが適切に読み込まれていないという意味なので、
viewを読み込むよう、HelloServiceProvider.phpに下記のuse宣言を追加すればOKです。

use Illuminate\Support\Facades\View;

スクリーンショット 2021-03-03 22.14.54.png
これで無事ブラウザ表示が確認できました。

参考記事はこちら
https://qiita.com/janet_parker/items/4b63804fe7f8aa3e74c6
https://qiita.com/niiyz/items/5b83ef5255a1ec64d9d6
https://stackoverflow.com/questions/63655253/error-class-app-providers-view-not-found

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelでquery parametersで空文字渡ってくるはずが謎にnullが取れる件

はじめに

こんにちは。楽天モバイルの楽天回線エリアに住んでますが、パートナー回線しか繋がらない筆者です。

さて、Laravel使ってまして、query parametersで空文字来てるはずなのに、nullになっちゃうことに地味に小一時間悩んだので共有します。

結論:こいつの仕業

Kernel.php
protected $middleware = [
    \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, // これ
];

こいつが空文字nullに変換しているんです... :sob:

地味に小一時間悩んだ話

以下を例にすると、2のときにnullになっているという恐ろしさ...。
そして、3のときも空文字が入っていたもののそれがnullに変換されてsetされているので、デフォルト値の1が適用されないという恐ろしさ...。

HomeController.php
public function index(Request $request)
{
    // 1: http://localhost?hoge=1
    $hoge = $request->input('hoge');
    dd($hoge); // 1

    // 2: http://localhost?hoge=
    $hoge = $request->input('hoge');
    dd($hoge); // null

    // 3: http://localhost?hoge=
    $hoge = $request->input('hoge', 1);
    dd($hoge); // null
}

勝手に変えてほしくない人は

以下コメントアウトしましょう:ok_hand:
あと、ちゃんとコメント書いておきましょう:pray:
https://github.com/laravel/laravel/blob/8.x/app/Http/Kernel.php#L23

おわりに

便利なのかもしれないのですが、良し悪しあると思います:thinking:
何かのお役に立てていれば幸いです。
それでは。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Could not open input file: artisan解決方法

エラー:Could not open input file: artisan

Laravelでサービスプロバイダを作成する為に、

$ php artisan make:provider HelloServiceProvider

上記のコマンドをターミナルで叩くと、

Could not open input file: artisan

というエラーが発生します。

このコマンドは、arisanファイルのあるディレクトリにいないと実行できない為、
エラーが発生するということは、コマンドを実行するカレントディレクトリが間違っている可能性があります。

現在どこのディレクトリで実行しているか確認しましょう。
スクリーンショット 2021-03-03 20.59.29.png
こちらの場合だと、

$cd docker-laravel-demo/backend

これで、artisanファイルのあるbackendディレクトリまで移動し、

$ php artisan make:provider HelloServiceProvider

サービスプロバイダを作成するコマンドを叩くと、

Controller created successfully.

こちらが表示され、Providersディレクトリ内にHelloServiceProviderファイルが作成されます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

爆速で検索できる顔文字サイトを開発した話

きっかけ

某S●mejiに対抗するため、えりんぎという顔文字サイトを2020年1月に公開

志半ばで中途半端な状態のまま放置してドメインを失効しそうになる
閉鎖しようと思ったが友人がずっと使ってくれてたのを知る

というわけでバグだらけの「えりんぎ」をリニューアルすることに

作ったもの

顔文字をカテゴリやキーワードで検索できるサイト
えもしぇあ https://emoshare.net

製作期間は3週間で60時間くらい
※顔文字を登録する作業が10時間以上占めている( ;꒳​; )

LighthouseでPerformanceが100にならなかったけど爆速
※Google Analytics入れたら92に下がった( ;꒳​; )
99.png

技術

Laravel 6.x (普段使っている安心安定のLaravel)
Vue3 (Composition API 使ってみたかった)
Tailwind CSS (Laravel8で採用されたらしいので使ってみたかった)

実装方針

実装に時間が掛かりそうなものはできるだけ除外
コンポーネントはあまり細かく分けない
新しいことはあまりしない(躓いたら諦める)

実装したい機能

検索したときにページ遷移せずいい感じで表示したい
タップやクリックでクリップボードにコピー
コピーした履歴を持ちたい
できるだけ爆速で表示したい
いいね機能
顔文字にキーワードを持たせて検索しやすくする(管理画面で設定するのめんどくさくなって途中から設定してない)
投稿(めんどくさくなってやめた)
ダークモード(めんどくさくなってやめた)
多言語化する(顔文字に日本語を含めなくなるのでやめた)

画面設計

5分で完了
爆速
Screenshot.png

DB設計

5分で完了
爆速
ER.png

実装

いいねボタン

これ参考にした

タグクラウド

チェックボックスの状態でON/OFFを切り替える

vue.js
// 一部省略
<label :for="'category' + category.id">
  <input
      :id="'category' + category.id"
      type="checkbox"
      class="category-input hidden"
  />
  <div class="category-button-wrapper">
    <span class="category-button">
      #{{ category.name }}
    </span>
  </div>
</label>
style.scss
.category-input {
  &:checked {
    + .category-button-wrapper {
      .category-button {
        color: #fff;
        background: #f59e0b;
        border-color: #f59e0b;
      }
    }
  }

  &:active {
    + .category-button-wrapper{
      .category-button {
        color: #fff;
        background: #fbbf24;
        border-color: #fbbf24;
      }
    }
  }
}

:checked:hover で実装したところスマホでうまく動かなかったが :hover:active に変えたら動いた

コピー履歴

Local Storageでコピーした顔文字を保持

localstorage.png

エラー画面

地味に 401, 403, 404, 405, 419, 429, 500, 503 エラーに対応した
error.png

gzip圧縮

cssとjsを圧縮する
zopfli使ったら結構小さくなった

tailwind.config.js
module.exports = {
  purge: [
    './resources/js/components/**/*.vue',
    './resources/views/**/*php'
  ],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [],
}
webpack.mix.js
const mix = require('laravel-mix')
const tailwindcss = require('tailwindcss')
const CompressionPlugin = require('compression-webpack-plugin')
const zopfli = require('node-zopfli')

mix.js('resources/js/app.js', 'public/js').vue()
  .sass('resources/sass/app.scss', 'public/css')
  .options({
    processCssUrls: false,
    postCss: [ tailwindcss('./tailwind.config.js') ],
  }).webpackConfig({
  plugins: [
    new CompressionPlugin({
      test: /\.(css)|(js)$/,
      algorithm: (content, options, fn) => {
        zopfli.gzip(content, options, fn);
      },
    })
  ]
})
.htaccess
# gzip対応
RewriteCond %{HTTP:Accept-Encoding} gzip
RewriteCond %{REQUEST_FILENAME}\.gz -s
RewriteRule ^(.+) $1.gz

<FilesMatch "\.css\.gz$">
    ForceType text/css
    AddEncoding x-gzip .gz
</FilesMatch>
<FilesMatch "\.js\.gz$">
    ForceType application/x-javascript
    AddEncoding x-gzip .gz
</FilesMatch>

デプロイ

レンタルサーバーにデプロイする方法忘れたのでこれを参考にした
ヘテムルにLaravelをデプロイする方法 (Laravel5.8)

最後に

Tailwind CSSのおかげで命名する時間を節約できたのはよかった
あとレスポンシブ対応も楽だった
Google Analytics入れたらBest Practicesが100にならないんだけどどうしたらいいの?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LaravelでNotification::fake()を使ったテストでNotification::assertSentToがうまく通らない

経緯

Laravelのテストで、Notification Fake
https://readouble.com/laravel/8.x/ja/mocking.html
を使用して、テスト時に実際にはslackに通知を飛ばさないようにしておいて、
Notification::assertSentTo
で、通知が送信される処理は行われていることをテストしようとして、

Notification::assertSentTo(
    new AnonymousNotifiable,
    SlackNotification::class
);

と書いたのだけど、どうあがいても

The expected [App\Notifications\SlackNotification] notification was not sent.
Failed asserting that false is true.

と怒られてしまう。
指定の仕方とか色々試したけど、assertSentToではどうあがいてもうまくテストが通らなかった。。。。

解決策

Notification::assertTimesSent
を使用することで解決

Notification::assertTimesSent(
    1,
    SlackNotification::class
);

第一引数に通知が送信されるべき回数(期待値)を指定してやればOK
テストでは一旦通知内容は確認しなくてOKだったので、通知が飛ばされる回数をテストしてやる形でテストが通った

通知が送られる処理の直前で

Notification::assertTimesSent(
    0,
    SlackNotification::class
);

と記述しても通ったので、とりあえず問題なさそう。

assertSentTo使う場合はどう書けばいいのかモヤモヤするけど、一旦忘れる!

追記

本記事を公開後、ちょっと調べてたら、以下の記事を見つけた
https://qiita.com/harukei/items/26f0a1c0d6128f681c00
Notification::fake() が Mail::fake() を上から潰しているようです。

確かに僕のコードでも

    public function test__invoke()
    {
        Mail::fake();
        Notification::fake();

とNotification::fake() と Mail::fake()の両方を使用しているので、assertSentToがうまく動作しないのはこのせいなのかもしれない。。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

動機

最近は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にあげています。

また、PHPを中心にした記事はこちらにも書いています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHPで簡単なアクセスログを取ろう!

今回作るもの

ユーザーがページにアクセスしたら
・日時
・ユーザーのIPアドレス
・ユーザーのホスト名
・ユーザーが利用しているポート番号
・アクセスされているページのURL
・リクエストメソッド
・遷移元URL

あまり深く考えずに選びましたが、必要に応じて追加してください。

コード

log.php
<?php
//保存するフォルダ(相対パス)
$directory='log/';
//日時取得
$time=date("Y/m/d H:i:s");
//ユーザーのipアドレスを取得
$user_id=$_SERVER["REMOTE_ADDR"];
if(!isset($_SERVER["REMOTE_HOST"])||$_SERVER["REMOTE_HOST"]==""){
    $_SERVER["REMOTE_HOST"]=gethostbyaddr($_SERVER['REMOTE_ADDR']);
}
//ユーザーのホスト名を取得
$user_host=$_SERVER["REMOTE_HOST"];
//ユーザーのポートを取得
$user_port=$_SERVER["REMOTE_PORT"];
if(empty($_SERVER["HTTPS"])){
     $url='http://';
}else{
     $url='https://';
}
//アクセス先URL取得
$url .=urldecode($_SERVER["HTTP_HOST"].$_SERVER["REQUEST_URI"]);
$url='"'.$url.'"';
$request=$_SERVER["REQUEST_METHOD"];
//元URLを取得
if(!isset($_SERVER['HTTP_REFERER'])||$_SERVER['HTTP_REFERER']==""){
    $motourl="empty";
}else{
    $motourl = urldecode($_SERVER['HTTP_REFERER']);
}
$motourl='"'.$motourl.'"';
//ファイル出力
$fp = fopen($directory.date("Y_m_d").'.csv', "a");
//一応文字コード指定
$str = mb_convert_encoding($time.','.$user_id.','.$user_host.','.$user_port.','.$url.','.$request.','.$motourl."\n", "UTF-8");
fwrite($fp,$str);
// ファイルを閉じる
fclose($fp);

読み込む

require('log.php');//ファイルへの相対パスは変更してください

アクセスログを記録したいページにこのコードを記述すればいろんなところで使いまわすことができます

以上!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Laravel】認証済みユーザーが"/register"にアクセスするとリダイレクトされるのはなぜか

初めに

タイトルの通りです。RegisterControllerコントローラーやRegisterUsersトレイトばかりに目が向いていて、ミドルウェアという選択肢を全く考えられなかったことへの戒めに記事に残しておきます。

前提

環境

  • PHP 7.4.15
  • Laravel 6.20.16
  • laravel/ui 1.3.0

今回の認証機能はlaravel/uiの以下のコマンド

php artisan ui:auth vue

によって提供されるものをそのまま使いました。

結論

先に答えを述べて、それから何故それが答えになるのかの説明をしていきたいと思います。

結論を言いますと、App\Http\Controllers\Auth\RegisterControllerクラスのコンストラクタに

app/Http/Controllers/Auth/RegisterController.php
public function __construct()
{
    $this->middleware('guest');
}

という処理によって、guestという名前のミドルウェアが適用されているからでした。

guestミドルウェアが追加されているコントローラは、認証済みユーザーからはアクセスできないようにミドルウェアによってガードされるようになります。

ではこの処理がどのように動作しているかを見てみましょう。

ミドルウェアguest

guestミドルウェアがどんな処理を行うかはApp\Http\Kernelをみればわかります。

App\Http\Kernel

  • 適用させるミドルウェアを登録したり
  • グループ分けを行ったり
  • 今回のguestのようにミドルウェアに対して個別に呼び出しを行えるように別名を付けたり

といったミドルウェアに関する情報を扱っています。

App\Http\Kernelクラスを見てみると、

app/Http/kernel.php
// 前略
protected $routeMiddleware = [
    'auth' => \App\Http\Middleware\Authenticate::class,
    'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
    'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
    'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
    'can' => \Illuminate\Auth\Middleware\Authorize::class,
    'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
    'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
    'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
    'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
// 後略

$routeMiddlewareプロパティに、[ミドルウェア名 => それの実装クラス]というような形式の連想配列の形で、あるミドルウェアに対してどの実装クラスを実行するかの情報が保存されています。

このプロパティ中のguestキーに対応するものは\App\Http\Middleware\RedirectIfAuthenticatedというクラスです。

名前からして、認証済みであればリダイレクトを行うという処理を担っていることが明らかですね。

ではこのクラスの実装を見てみましょう。

RedirectIfAuthenticatedクラスの処理

app/Http/Middleware/RedirectIfAuthenticated.php
<?php

namespace App\Http\Middleware;

use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Support\Facades\Auth;

class RedirectIfAuthenticated
{
    public function handle($request, Closure $next, $guard = null)
    {
        if (Auth::guard($guard)->check()) {
            return redirect(RouteServiceProvider::HOME);
        }

        return $next($request);
    }
}

コメントを省くとこれだけの処理です。

リクエストを受けたときに実行されるメソッドであるhandle()メソッドの中身を見てみましょう。

まずAuth::guard($guard)->check()で既にユーザー情報があるかを判断しています。

既にユーザー情報がある、つまりログイン済みであれば

return redirect(RouteServiceProvider::HOME)

RouteServiceProvider::HOMEにリダイレクトする、という処理を行っています。

この処理から、App\Providers\RouteServiceProviderクラスのHOME定数を変更すればリダイレクト先を変更できるということもここから読み取れますね。

ユーザー情報がまだ無ければこのミドルウェアでは何もせず、$next($response)によって次のミドルウェアへ処理を渡しています。

以上のことから、確かにこのミドルウェアによって認証済みユーザーに対してのアクセスガードが実装されていることが分かりました。

終わりに

認証済みユーザーが/registerにアクセスしたときにリダイレクトする処理は、guestミドルウェア、つまり\App\Http\Middleware\RedirectIfAuthenticatedクラスによって提供されていることが分かりました。

ここでは各処理の詳細については深くは追わず、簡略化した説明となっています。

もし各処理の詳細(たとえば、ミドルウェアの実行される流れだったり、リダイレクト関数の実装)が気になる方がいらっしゃいましたら、ぜひ一度laravelのソースコードを深堀して追ってみるという経験をなさってみてはいかがでしょうか。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

xdebug3 でデバッグする

いつのまにかxdebugが更新されて古い設定のままだと動かなくなってたのでiniファイルの変更点をざっくり

xdebug.iniの変更点

変更前(xdebug2)

確かこんな感じ

zend_extension=xdebug
xdebug.remote_enable =1
xdebug.remote_autostart=1
xdebug.remote_host=host.docker.internal
xdebug.remote_port=9000

変更後(xdebug3)

動いた設定

zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=1
xdebug.client_host=host.docker.internal
xdebug.client_port=9003

デフォルトのポート番号も9000から9003に変更になっているようです。
IDE側を直すか、xdebug側を変えるかはお好みで。

前提

使用しているDockerfileです。

FROM php:8.0-fpm

RUN pecl install xdebug \
    && docker-php-ext-enable xdebug

COPY docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

EUC-JPでpreg_replace()を使いたいので代替になる方法を見つけた

結論

配列を渡す機能は代替できないが、参照は(もっと柔軟な形で)代替できる。そう、 mb_ereg_replace_callback() を使えばね。

環境

PHP5.4.1以降、mbstringが有効になっている必要があります。

前提

PHPのマルチバイト文字対応は、UTF-8が前提になっているものがあります。 preg_replace() 関数もその一つです。この関数はマルチバイト文字に対応していますが、UTF-8以外の文字コードには対応していません。
しかし例えばEUC-JPなど、どうしても古い文字コードを使わなければならない環境はあると思います。
そんな中でこれの代替になりそうなのは、 mb_ereg_replace() ですが、これは配列渡しや参照(${0}とかのやつです)が利用できないという弱点があります。
でも、UTF-8以外のマルチバイト文字で参照を利用したreplaceをやりたい!という方のための記事です。

説明

UTF-8以外のマルチバイト文字で参照を利用したreplaceが出来ないかというと、そんなことなかったです。 mb_ereg_replace_callback() がありました。
この関数は第2引数に関数をとることができます。その関数の引数に、第1引数の正規表現でマッチした部分が配列として入ります。これを利用すれば参照が使えます。しかも、関数の中で使えるのでもっと柔軟な形で利用できます。

サンプルコード

<?php

// 念のため利用するエンコーディングをEUC-JPに設定しておきます。
// php.iniの設定次第では、この行は要りません。
mb_internal_encoding("EUC-JP");
// echo mb_internal_encoding() . "\n";

$text = "あいうえお かきくけこ さしすせそ たちつてと\n";
$text .= "なにぬねの はひふへほ まみむめも やいゆえよ\n";
$text .= "らりるれろ わおん\n";

// パターンは正規表現ですが、スラッシュで挟む必要はありません。
// また、少なくともEUC-JPを用いた場合、.は改行文字も含むようです。
$pattern = "([^ \n]+)([ \n])";

// 第2引数は無名関数を使うことができます。
// これにより、コールバック関数としてだけ使いたい関数があちこちに散らばるのを抑制できます。
// もちろん名前をつけた関数を利用することもできます。
// その方法はPHPの公式マニュアルを読まれるといいでしょう。
$result = mb_ereg_replace_callback(
  $pattern,
  function ($matches) {
    if ($matches[2] === "\n") {
      return $matches[1] . "," . $matches[1] . $matches[2];
    } else {
      return $matches[1] . "," . $matches[1] . ",";
    };
  },
  $text
);

echo $text;

echo "\n";

echo $result;

// $resultの中身は以下です。
// あいうえお,あいうえお,かきくけこ,かきくけこ,さしすせそ,さしすせそ,たちつてと,たちつてと
// なにぬねの,なにぬねの,はひふへほ,はひふへほ,まみむめも,まみむめも,やいゆえよ,やいゆえよ
// らりるれろ,らりるれろ,わおん,わおん

echo "\n";

ここで、第2引数の関数の引数にマッチした部分が入ります。その添字と中身の関係は、通常の参照の関係と同じで、0にマッチした部分全体、1以降はサブパターンにマッチしたテキストが入ります。

まとめ

こんな感じで参照を使ったpreg_replace()の代替ができました。ちなみにpreg_replace()の方でコールバック関数を使いたいのならば、 preg_replace_callback() があるようです。
どうでもいいですが、Qiitaって全角文字は等幅フォントじゃないのですね。この記事書いてて気付きました。

参考文献

https://www.php.net/manual/ja/function.mb-ereg-replace-callback.php
https://www.php.net/manual/ja/function.mb-ereg-replace.php
https://www.php.net/manual/ja/function.preg-replace.php
https://www.php.net/manual/ja/function.preg-replace-callback.php

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PrivateTmp に残ったファイルの処理

systemd が作成する PrivateTmp に、ユーザがアップロードするファイルが大量に残っていてOSの容量を圧迫していたので、適当に消すスクリプトを作成。

/root/remove_files_in_php_fpm_private_tmp.sh

#!/bin/bash

PHP_FPM_PRIVATE_TEMP=$(find /tmp -type d -name "*php-fpm*")
PHP_FPM_PRIVATE_TEMP="${PHP_FPM_PRIVATE_TEMP}/tmp"
find "${PHP_FPM_PRIVATE_TEMP}" -type f -exec rm {} \;

exit 0

cron で適当に実行

# crontab -e
...(snip)...
# removing php-fpm private temp files (not a directory)
5 * * * * /root/remove_files_in_php_fpm_private_tmp.sh >/dev/null
...(snip)...

ちなみに、ディレクトリを消しちゃうと、アップロードができなくなる。
その場合は、php-fpm を再起動すると、PrivateTmp が再作成される。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フレームワークを使わないPHP+Javascript TIPS

サイトマップを作る

記載するほどでもないが、サイトマップを作るコード。
最終的にxmlファイルを生成するが、phpファイルのままでもいけるかもしれない。

make_stemap.php
$xml = '<?xml version="1.0" encoding="utf-8" ?>'."\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
foreach($array_movie_id as $value){
    $xml .= "<url>";
    $xml .= "<loc>https://~/".$value['key']."</loc>";
    $get_date = $value['key2'];
    $mod_date = substr($get_date, 0, -9);
    $xml .= "<lastmod>".$mod_date."</lastmod>";
    $xml .= "</url>";
}
$xml .= "</urlset>";
<urlset>
file_put_contents($_SERVER['DOCUMENT_ROOT'].'/xml/sitemap.xml', $xml);

ページを複製する

通常1つのページを作成し、PPOSTで渡した値で表示を変えるのが通例だが、
SEOの観点から複数のページを作成したいときがある。

元ファイルがJavascriptの場合

定型部分をJavascriptで記載し、incファイル化
ob_start()関数を使用。元ファイルがJavascriptなのでPHPの変数の影響を受けることなく記載可能。

a.inc
コード内部はJavascriptで記載。
b.php
データベースに接続等
ob_start(); //バッファ制御スタート
include("inc/a.inc");
$text = ob_get_clean(); //バッファ制御終了&変数を取得
$something = 可変部分
$something .= $text;

元ファイルがphpの場合

複製元でデータベースに接続していたり、include関数を使っていたりすると複製するためのPHPの段階では上手く回っても、複製先ではphpファイルを読み込まない。

foreachの中でディレクトリも作成し、その中にコピーを作成。

a.php
$path = '../directory';
foreach($array as $value){
    if(in_array($value['key'], $file_name_all)){

    }else{
        mkdir($path."/".$value['key']);
        copy('b.php', $path."/".$value['key']."/index.php");
    }
}
?>

$value[key]というディレクトリーの中にindex.phpというファイルが生成される。
indexと名前がつけばURLにディレクトリを入れるだけでそのページを表示するので、楽。

ajaxによる非同期通信(jQueryを用いた場合)

毎回迷うので、備忘録もかねて。

a.php
//データベースに書き込むためのphp
$.ajax({
   url: 'b.php',
   type: 'post', 
   dataType: 'json', 
   data: { // 送信データを指定。上記のURLに$_POST[~]として送信。
         name: $('#name').val(),//jQueryの文法。id="name"のvalueを取得。
         id: $('#id').val(),
         }
     })

読み込みについては後日記載。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

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

Step 1. 整形する

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

<?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

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

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

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

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

<?php

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

これはnull合体演算子という、なんだかかっちょいい名前の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

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

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

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

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

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

@mpyw$_GET, $_POSTなどを受け取る際の処理 - Qiitaを読んできてください。読みましたね? おめでとうございます。これであなたもPHPマスターです。

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

<?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も弾かれてしまいます。

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

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

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

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

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

これはド・モルガンの法則 - Wikipediaによるものです。私は算数がとても苦手ですが名前がおもしろいのでよく覚えています。

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

PHPはデフォルトではContent-type: text/html; charset=UTF-8として取り扱うので、HTML以外のデータ形式を出力する場合はContent-Typeを適切にセットする必要があります。

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

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

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

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

神は細部に宿ります。

<?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メソッドはGETHEAD以外は許可せず405を返すことにします
    • [405 Method Not Allowed - HTTP | MDN]
    • 一般的なWeb APIはHTTPメソッドが固定されています
    • フレームワークを使わないPHPではHTTPメソッドの判定が甘くなりがちです (https://developer.mozilla.org/ja/docs/Web/HTTP/Status/405)
    • HEADメソッドをサポートする必要があるかといえばないのですが、気分です
  • 意図したパラメータが渡されなかった場合は400を返すことにします

まとめ

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

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

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

私からは以上です。

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

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

<?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;
}

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

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


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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む