Slim4で例外発生時の表示をTwig表示できるようにカスタマイズ

Slim4で例外の表示

Slim4スケルトンが理解できるくらいの知識があることを前提としてます。

まずSlim4で例外が発生したとき、なんかそっけない感じのエラーページが表示されます。

例えば/app/shiritori/list

throw new Exception('KURAGE Waku Waku Exception!');

という例外を発生させたとしましょう。

こんな感じのページが表示されます。

もしテンプレートエンジンにTwigを使って開発されているなど、
それに合わせて例外ページのそのデザインを適用したいはずです。

後Messageのところに例外メッセージのKURAGE Waku Waku Exception!が表示してあることがわかります。
しかも開発環境ならともかく実際の運用でエラー内容を事細かに表示しているのはよろしくありません。

エラーページのデザインも統一したい、エラー表示を制限したいなど、今回はこの辺をまとめて探っていきます。

まずはSlim\Appインスタンスを作成すると思います。

$app = AppFactory::create();

そのAppにエラー用のミドルウェアを追加するのがaddErrorMiddleware()です。

$errorMiddleware = $app->addErrorMiddleware(true, false, false);

このメソッドはミドルウェアにSlim\Middleware\ErrorMiddlewareを追加します。
第一引数のdisplayErrorDetailsは、現在の実行環境が開発モードならtrue、プロダクションモードならfalseを渡します。
この真偽によってエラーの表示内容が変わります(さすがにプロダクションモードで生々しい例外表示するのはダメでしょ)。

もしプロダクションモードで表示するとこのようになります。

このミドルウェアは自分以降のミドルウェアで発生した例外をキャッチして適切なレスポンスを返します。
例外の型に応じてレスポンスを返すようになってますが、そのためにはまず例外の型に応じたエラーハンドラを設定します。

例えば独自の例外を作成したとしましょう。

use Exception;

class KuragePukaPukaException extends Exception
{

}

この例外をさっきの/app/shiritori/listで発生させたとします。
そのアクションが大体次のようになっていたとします(名前空間とは省きます)。

class ShiritoriList extends ViewAction
{

    public function __construct(private IShiritoriRepository $repository, Container $container)
    {
        parent::__construct($container);
    }

    protected function action(): ResponseInterface
    {
        $uid = $this->getUserId();
        $page = $this->request->getAttribute('page') ?? 1;

        $entities = $this->repository->findByUserId($uid);

        $renderer = new PaginationRenderer(
            new ArrayLimitableQuery($entities),
            3,
        );

        // 独自の例外発生させたる!
        throw new KuragePukaPukaException('クラゲプカプカエクセプション!', 500);

        return $this->twigRender(
            'app/shiritori/list.twig',
            [
                'render' => $render
            ]
        );
    }
}

このように独自に作った例外KuragePukaPukaExceptionを発生させます。
さっきと同じようにエラーが表示されます。

一応TypeでKuragePukaPukaExceptionやMessageでクラゲプカプカエクセプション!が確認出来ます。

独自のエラーハンドラ

この表示を変えていきます。
表示を変えるというのは、この例外に対応したレスポンスを作成するということです。

まずaddErrorMiddleware()は戻り値にErrorMiddlewareのインスタンスを返します。
このインスタンスにエラーハンドラを設定します。

次のようなコードになります。

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Interfaces\ErrorHandlerInterface;

$app = AppFactory::create();
$errorMiddleware = $app->addErrorMiddleware(true, false, false);

$errorMiddleware->setErrorHandler(
    KuragePukaPukaException::class,
    new class($app) implements ErrorHandlerInterface
    {
        public function __construct(private App $app){ }

        public function __invoke(
            ServerRequestInterface $request,
            Throwable $exception,
            bool $displayErrorDetails,
            bool $logErrors,
            bool $logErrorDetails): ResponseInterface
        {
            // レスポンスを作成
            $response = $this->app->getResponseFactory()->createResponse(500);

            /**
             * DIからTwigの取得
             * @var Twig $twig
             */
            $twig = $this->app->getContainer()->get(Twig::class);

            $title = 'Error';
            $body = $displayErrorDetails ? $exception->getMessage() : 'Puka Puka Error!';

            // Twigで$titleと$bodyを渡してHTMLをレンダリング
            $html = $twig->fetch(
                'errors/html.error.twig',
                ['title' => $title, 'body' => $body]
            );

            // Twigで作成したHTMLをレスポンスに書き込む
            $response->getBody()->write($html);

            return $response;
        }
    }
);

// 以下主略・・・

もう一度アクセスするとデザインが適用されています。

ErrorMiddlewareのsetErrorHandler()メソッドで例外の型情報と、その例外が発生されたときに返すエラーハンドラを渡します。
エラーハンドラはSlim\Interfaces\ErrorHandlerInterfaceを実装したインターフェースです。

この実装例では、すでにDIにTwigインスタンスが登録してあることを前提にしてます。
Appインスタンスを通してレスポンスやDIからTwigインスタンスを取得してますが、この辺は適切に実装してください。

$body = $displayErrorDetails ? $exception->getMessage() : 'Puka Puka Error!';

$displayErrorDetailsに応じてエラーの内容を変えてます。
開発モードでは例外のgetMessage()で取得できるクラゲプカプカエクセプション!を表示しますが、
プロダクションモードではPuka Puka Error!を表示するようにしてます。

ちなみにTwigで書いたテンプレート(errors/html.error.twig)です。

{% extends "layout/index.twig" %}

{% block title %} {{title}} {% endblock %}

{% block heading %} {{title}} {% endblock %}

{% block content %}
    <div class="err-message">
        {{ body|raw }}
    </div>
{% endblock %}

実際にはテンプレート(layout/index.twig)を用意していてHTMLのほとんどはそっちに書いてありますが、これで大体の内容を想像してください。
これで例外に応じたレスポンスの返し方がわかりました。

デフォルトのエラーハンドラとエラーレンダラ

ただ、これ、例外毎に毎回書くのって大変ですよね、そうですよね!

そこで、もし例外に対応するエラーハンドラが設定されていなかったら、ErrorMiddlewareはデフォルトのエラーハンドラを使います。
そのデフォルトのエラーハンドラというのがSlim\Handlers\ErrorHandlerです。

ErrorMiddlewareからgetDefaultErrorHandler()メソッドで取得出来ます(設定もできます)。
このエラーハンドラはMIMEタイプ(HTTPのContent-Type)に応じてエラーの表示を変えます。
例えばtext/htmlであればHTMLがレスポンスされ、application/jsonであればJSON形式でレスポンスされます。
他にtext/xmlや{application/xmlではXMLで、text/plain`では、といったように。

text/htmlではそのHTMLを作成するのにSlim\Error\Renderers\HtmlErrorRendererが登録されています。
同じようにJSONではSlim\Error\Renderers\JsonErrorRendererが、というようにレンダラクラスが登録されています。

これらは独自に登録することもできます。
Slim\Interfaces\ErrorRendererInterfaceを実装することで任意のMIMEにエラーレンダラが登録できます。

use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Interfaces\ErrorRendererInterface;

$app = AppFactory::create();
$errorMiddleware = $app->addErrorMiddleware(true, false, false);

// デフォルトのエラーハンドラ(ErrorHandler)を取得
$handler = $errorMiddleware->getDefaultErrorHandler();

// text/htmlに再登録
$handler->registerErrorRenderer('text/html', new class($app) implements ErrorRendererInterface{
    public function __construct(private App $app){ }
    public function __invoke(Throwable $exception, bool $displayErrorDetails): string
    {
            /**
             * DIからTwigの取得
             * @var Twig $twig
             */
            $twig = $this->app->getContainer()->get(Twig::class);

            $title = 'Error';
            $body = $displayErrorDetails ? $exception->getMessage() : 'なんかサーバー内でエラーが起こったんだよね!';

            // Twigで$titleと$bodyを渡してHTMLをレンダリング
            $html = $twig->fetch(
                'errors/html.error.twig',
                ['title' => $title, 'body' => $body]
            );

            return $html;
    }
});

// 以下省略・・・

ErrorHandlerのregisterErrorRenderer()で登録しましたが。
text/htmlはすでにHtmlErrorRendererが登録してありますので、上書したことになります。

後はアクションで例外を発生させます。

throw new Exception('Kurage Waku Waku Genki!');

HtmlErrorRendererの継承

でもHtmlErrorRendererをそのままぱくっちゃった方が楽そうです。
開発モードでエラーの詳細も知りたいことだし、それをいちいち作るのは面倒です。
そのままHtmlErrorRendererを継承してしまいましょう。

use Slim\Factory\AppFactory;
use Slim\Error\Renderers\HtmlErrorRenderer;
use Slim\Views\Twig;

// HtmlErrorRendererをそのまま継承しちゃうゼ!
class HtmlErrorRendererExtends extends HtmlErrorRenderer
{

    protected $defaultErrorTitle = 'エラー';

    protected $defaultErrorDescription = 'サーバー内でエラーが発生しました。';

    public function __construct(private Twig $twig)
    {

    }

    public function renderHtmlBody(string $title = '', string $html = ''): string
    {
        return $this->twig->fetch('errors/html.error.twig', ['title' => $title, 'body' => $html]);
    }
}

$app = AppFactory::create();
$errorMiddleware = $app->addErrorMiddleware(true, false, false);

// デフォルトのエラーハンドラ
$handler = $errorMiddleware->getDefaultErrorHandler();

// 独自のエラーレンダラを上書。
$handler->registerErrorRenderer('text/html', HtmlErrorRendererExtends::class);

// 以下省略・・・

まずは開発者モードで開いたときの表示

次にプロダクションモードで開いたときの表示。

開発者モードではエラーを詳細に表示し、プロダクションモードの時は最小限の表示されます。
renderHtmlBody()メソッドをオーバーライドしてTwigによる表示をしてます。

元あるクラスを継承しているので、
親クラスではエラーのHTMLにpタグなど邪魔なタグが入ってますがCSSあたりで制御して使ってます。

$defaultErrorTitle$defaultErrorDescriptionには秘密があります。
これらはHtmlErrorRendererのさらに親クラスに定義されていて、
今回はそれらをオーバーライドすることでデフォルトの表示を少し変えています。

HttpException

独自の例外を作成するとき通常はExceptionをそのまま継承すると思いますが、
Slim4ではSlim\Exception\HttpExceptionというSlim4用の例外クラスを用意してます。
HttpExceptionクラス自体は通常のExceptionクラスの派生クラスです。

実はSlim4がフレームワークレベルで例外を発生させるとき、それらの例外はこの例外クラスの派生です。
例えば、存在しないルートにアクセスしてしまった場合(404)ルートミドルウェアはSlim\Middleware\HttpNotFoundExceptionという例外を発生させます。
他にもエラーコード400のSlim\Middleware\HttpBadRequestExceptionや403のSlim\Middleware\HttpForbiddenExceptionなどが用意されています。

HttpExceptionはリクエストをコンストラクタで受け取る他、
プロパティに$title$description、さらにgetTitle()/setTitle()とgetDescription()/setDescription()などのセッター/ゲッターがあります。

では実際に実装してみます。

use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpException;

class KuragePukaPukaException extends HttpException
{
    protected $title = 'プカプカエラー';
    protected $description = '毒クラゲにさされたら痛いので注意してください!';

    public function __construct(
        ServerRequestInterface $request, string $message)
    {
        parent::__construct($request, $message, 500);
    }

}
// リクエストの設定は環境に合わせて行ってください。
$ex = new KuragePukaPukaException($this->request, 'クラゲプカプカエクセプション!');

echo sprintf(
    " title: %s\n description: %s\n message: %s\n",
    $ex->getTitle(),
    $ex->getDescription(),
    $ex->getMessage()
);
 title: プカプカエラー
 description: 毒クラゲにさされたら痛いので注意してください!
 message: クラゲプカプカエクセプション!

と、たったこれだけです。

AbstractErrorRenderer

実はHtmlErrorRendererなどは発生する例外がHttpException派生かどうかでちょっとだけ表示が変わります。

$handler->registerErrorRenderer('text/html', HtmlErrorRendererExtends::class);

もしこの設定をしていたら削除して元のHtmlErrorRendererでレンダリングさせるようにしてください。
もうまっさら初期の状態ですね。
そしてプロダクションモードに変更してください。

まずは通常の例外を放った時の表示です。

throw new Exception('クラゲプカプカエクセプション!');

タイトルがSlim Application Errorで、
詳細がA website error has occurred. Sorry for the temporary inconvenience.
になっています。

次にHttpExceptionを実装した例外を放った時の表示です。

throw new KuragePukaPukaException($this->request, 'クラゲプカプカエクセプション!');

HttpExceptionのgetTitle()/getDescription()で取得出来る値になってます。

HtmlErrorRendererやJsonErrorRendererはどちらもSlim\Error\AbstractErrorRendererを継承してます。
このクラスは、protectedな$defaultErrorTitle$defaultErrorDescriptionが定義してあります。
そして、getErrorTitle()及びgetErrorDescription()があります。

コメント類は省略しますが、AbstractErrorRendererは次のようになってます。

abstract class AbstractErrorRenderer implements ErrorRendererInterface
{
    protected $defaultErrorTitle = 'Slim Application Error';
    protected $defaultErrorDescription = 'A website error has occurred. Sorry for the temporary inconvenience.';

    protected function getErrorTitle(Throwable $exception): string
    {
        if ($exception instanceof HttpException) {
            return $exception->getTitle();
        }

        return $this->defaultErrorTitle;
    }

    protected function getErrorDescription(Throwable $exception): string
    {
        if ($exception instanceof HttpException) {
            return $exception->getDescription();
        }

        return $this->defaultErrorDescription;
    }
}

getErrorTitle()/getErrorDescription()は、例外の種類がHttpException派生であればそのタイトルと詳細を、
違えばデフォルトのタイトルと詳細を使用します。

このクラスの派生クラスを作る際、デフォルトのプロパティをオーバーライドすることで変更することが可能だったのです。

class HtmlErrorRendererExtends extends HtmlErrorRenderer
{
    // 通常例外の場合に表示するタイトル
    protected $defaultErrorTitle = 'エラー';

    // 通常例外の場合に表示する詳細
    protected $defaultErrorDescription = 'サーバー内でエラーが発生しました。';

    public function __construct(private Twig $twig)
    {

    }

    public function renderHtmlBody(string $title = '', string $html = ''): string
    {
        return $this->twig->fetch('errors/html.error.twig', ['title' => $title, 'body' => $html]);
    }
}

このように、デフォルトから上書しています。
ただし、getErrorTitle()やgetErrorDescription()がどう使われているかはエラーレンダラによって違うので注意してください。
例えはJSONようのエラーレンダラはgetErrorDescription()は呼び出してません。

詳しく知りたい方はHtmlErrorRendererの中身を覗いて見るといいでしょう。
同じくJsonErrorRenderer等ものぞくと参考になります。

certificate Docker Gutenberg Hyper-V openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク