📚

Laravel: APIの例外はJSONを返そう

2022/03/26に公開
4

API へのリクエストで例外が発生したとき、Laravel デフォルトの HTML が返ってくるよ

JSON を受け取る気でいたのに、HTML がやってきた。
例外が発生したのね。わかるけどね。
HTML 渡されても扱いづらいのよね。

JSON で例外レスポンスを返すようにする

ドキュメントにも例があるけど、 App\Exceptions\Handler にカスタムレンダリングクロージャを登録することでオーバーライドできる。

Laravel のデフォルトのエラーページテンプレートには以下のステータスコードのものがあるので、これらの例外が発生した場合には代わりに JSON レスポンスを返すようにする。

  • 401: Unauthorized
  • 403: Forbidden
  • 404: Not Found
  • 419: Page Expired
  • 429: Too Many Requests
  • 500: Server Error
  • 503: Service Unavailable

実際のクロージャ

app/Exceptions/Handler.php の register メソッド内で、renderable メソッドを使って登録する。

パスが api/ で始まる場合のみ処理の対象とする。値を返さなければデフォルトの例外レンダーが使われるので、条件に当てはまらなければ元どおりの HTML が返るはず。

HttpException のステータスコードでスイッチしてレスポンスの内容を変える。

JSON の中身はエラーページテンプレートと同じ内容で、形式は RFC7807 風にしてみた。場合によってはもうちょっと詳しい情報を出してもいいかもしれない。

※2023/1/12 更新: 以前のコードだと内部エラーを拾いきれないのでちょっと変えてみました。

app/Exceptions/Handler.php
// :
use Symfony\Component\HttpKernel\Exception\HttpException;
// :

class Handler extends ExceptionHandler
{

    // :

    /**
     * Register the exception handling callbacks for the application.
     *
     * @return void
     */
    public function register()
    {

        // :

        $this->renderable(function (Throwable $e, $request) {
            if ($request->is('api/*')) {
                $title = '';
                $detail = '';

                if ($e instanceof HttpException) {
                    $cast = fn ($orig): HttpException => $orig;  // HttpException へ型変換
                    $httpEx = $cast($e);
                    switch ($httpEx->getStatusCode()) {
                        case 401:
                            $title = __('Unauthorized');
                            $detail =  __('Unauthorized');
                            break;
                        case 403:
                            $title = __('Forbidden');
                            $detail = __($httpEx->getMessage() ?: 'Forbidden');
                            break;
                        case 404:
                            $title = __('Not Found');
                            $detail = __('Not Found');
                            break;
                        case 419:
                            $title = __('Page Expired');
                            $detail = __('Page Expired');
                            break;
                        case 429:
                            $title = __('Too Many Requests');
                            $detail = __('Too Many Requests');
                            break;
                        case 500:
                            $title = __('Server Error');
                            $detail = config('app.debug') ? $httpEx->getMessage() : __('Server Error');
                            break;
                        case 503:
                            $title = __('Service Unavailable');
                            $detail = __('Service Unavailable');
                            break;
                        default:
                            return;
                    }

                    return response()->json([
                        'title' => $title,
                        'status' => $httpEx->getStatusCode(),
                        'detail' => $detail,
                    ], $httpEx->getStatusCode(), [
                        'Content-Type' => 'application/problem+json',
                    ]);
                }

                // HttpException 以外の場合
                $title = __('Server Error');
                $detail = config('app.debug') ? [
                    'message' => $e->getMessage(),
                    'code' => $e->getCode(),
                    'file' => $e->getFile(),
                    'line' => $e->getLine(),
                    'trace_str' => $e->getTraceAsString(),
                    'trace' => $e->getTrace()
                ] : __('Server Error');

                return response()->json([
                    'title' => $title,
                    'status' => 500,
                    'detail' => $detail,
                ], 500, [
                    'Content-Type' => 'application/problem+json',
                ]);
            }
        });
        
        $this->reportable(function (Throwable $e) {
            //
        });

        // :

    }
}
2023/1/12 以前のコード
app/Exceptions/Handler.php
// :
use Symfony\Component\HttpKernel\Exception\HttpException;
// :

class Handler extends ExceptionHandler
{

    // :

    /**
     * Register the exception handling callbacks for the application.
     *
     * @return void
     */
    public function register()
    {

        // :

        $this->renderable(function (HttpException $e, $request) {
            if ($request->is('api/*')) {
                $title = '';
                $detail = '';

                switch ($e->getStatusCode()) {
                    case 401:
                        $title = __('Unauthorized');
                        $detail =  __('Unauthorized');
                        break;
                    case 403:
                        $title = __('Forbidden');
                        $detail = __($e->getMessage() ?: 'Forbidden');
                        break;
                    case 404:
                        $title = __('Not Found');
                        $detail = __('Not Found');
                        break;
                    case 419:
                        $title = __('Page Expired');
                        $detail = __('Page Expired');
                        break;
                    case 429:
                        $title = __('Too Many Requests');
                        $detail = __('Too Many Requests');
                        break;
                    case 500:
                        $title = __('Server Error');
                        $detail = __('Server Error');
                        break;
                    case 503:
                        $title = __('Service Unavailable');
                        $detail = __('Service Unavailable');
                        break;
                    default:
                        return;
                }

                return response()->json([
                    'title' => $title,
                    'status' => $e->getStatusCode(),
                    'detail' => $detail,
                ], $e->getStatusCode(), [
                    'Content-Type' => 'application/problem+json',
                ]);
            }
        });

        // :

    }
}

404 なんかは試しやすいですね。存在しない適当なパスを入力してみて、api/ から始まる場合とそうでない場合のレスポンスの違いを確認できます。

Discussion

eueuGeueuG

初めまして。質問があります。
2023/1/12 以降のコードで401のエラーをキャッチしようとすると自分の環境(php 8.1 laravel 9.19)だとAuthenticationExceptionがthrowされるのですが、
$e instanceof HttpException がfalseになって401が500で返ってきてしまいます。
想定した挙動にならないので上のコードの内容を確認して欲しいです。
2023/1/12 以前のコードはめっちゃ参考になりました!使わせていただいています!

blancpandablancpanda

初めまして。記事を読んでいただいてありがとうございます。
ご質問の件について少し調べてみました。

instanceof HttpException では入ってきた例外が HttpException を継承しているかどうかをチェックしているのですが、Illuminate\Auth\AuthenticationException は HttpException を継承していません。
以前のコードではすり抜けるのでデフォルトの HTML が返るのではないかと思いますが、ここでステータスコード 401 の JSON を返したい場合は自分で変換する必要があります。

"HttpException 以外の場合"のところを、

// HttpException 以外の場合
$title = __('Server Error');
$status = 500;

if($e instanceof AuthenticationException) {
    $title = __('Unauthorized');
    $status = 401;
}

$detail = config('app.debug') ? [
    'message' => $e->getMessage(),
    'code' => $e->getCode(),
    'file' => $e->getFile(),
    'line' => $e->getLine(),
    'trace_str' => $e->getTraceAsString(),
    'trace' => $e->getTrace()
] : $title;

return response()->json([
    'title' => $title,
    'status' => $status,
    'detail' => $detail,
], $status, [
    'Content-Type' => 'application/problem+json',
]);

などとすればいいのではないかと思います(検証はしてません、すみません)。

eueuGeueuG

回答ありがとうございます!
AuthenticationException, NotFoundExceptionなどを適宜instansceofでチェックするのが無難ですかね。。。

blancpandablancpanda

おそらくドキュメントには載っていない部分なので、本体がこのあとどうレンダリングをしているのかソースコードを見てみました。
Illuminate\Foundation\Exceptions\Handler(App\Exceptions\Handler の親クラスです)

わかったことは時間があったら追記するか別記事を書くかしようと思いますが…

前提として、リクエストヘッダに Accept: 'application/json' が設定されていれば Laravel は JSON を返そうとしてくれます。もし API の呼び出し側を作っていて、単純に JSON で返ってくればいいということならば、ここで特に何もしなくても呼び出し側でヘッダをセットすればよさそうです。

とはいえそのままでは扱いづらいので独自の JSON レスポンスに変換するという方針でいくと、ここで登録しているレンダリング関数が呼び出される前に大抵の例外が HttpException に変換されているようです。本体側で HttpException 以外で対処しているのは、
Illuminate\Http\Exceptions\HttpResponseException
lluminate\Auth\AuthenticationException
Illuminate\Validation\ValidationException
の3つで、instanceof でチェックするのはこれらでよさそうです。
HttpResponseException に関しては中身がレスポンスそのものなので、本体と同様に $e->getResponse() でいい気がします(必要なら throw する側で JSON を入れておけばいいかと)。