Help us understand the problem. What is going on with this article?

Laravelでエラーコードを含む独自エラーレスポンスを返す方法

はじめに

この記事はLaravel Advent Calendar 2018 4日目の記事です。

Laravelを利用してRESTful APIを開発する際、標準でエラーレスポンス機能が提供されますが、クライアント側で詳細な情報を取得したい要件がある場合には機能不足です。本記事ではエラーコードを含む独自エラーレスポンスを返す方法を説明します。

環境

  • PHP7.2
  • Laravel 5.7.15

問題提起

RESTful APIでエラーが発生した際は、基本的にはHTTPステータスコードで内容を判断するのが一般的で、小規模なアプリケーションであればHTTPステータスコードで十分対応できます。しかし、アプリケーション固有のエラーが発生した場合はステータスコードだけでは判断できない場合が起きます。例えばとあるユーザー情報を閲覧しようとした際に403 Forbiddenが発生しましたが、そのユーザーの契約期限が切れたのが原因だった場合に以下のメッセージが返ってきたとします。このメッセージは人間向けの理解しやすいエラーメッセージではありますが、プログラムのハンドリングには不向きです。

{
  "message": "user contract is expired."
}

どのようにして解決すべきでしょうか。

解決方法

Web APIのエラーレスポンスについては、@suin さんの WebAPIでエラーをどう表現すべき?15のサービスを調査してみた - Qiita がとても素晴らしいです。この記事によりますと、著名ウェブサービスのAPIのエラーレスポンスでは、人間が読んで理解できるメッセージは必ず含まれており、次にプログラムがハンドリングしやすいstringintのエラーコードフィールドが含まれているとのことです。

個人的には型はintだとその対応表を別途用意しないと意味が判断できないのでstringに。フィールド名はエラーレスポンスの中のフィールドということもあり code であれば十分意味も通ると考えています。

まとめると以下のようなレスポンスを返すのが良さそうです。

{
  "message": "User contract is expired on 2018-01. blabla",
  "code": "cotract_expired",
}

実装手順

それでは早速実装例をご紹介します。

1. エラーレスポンスの根底クラスを作成する

app/Exceptions/BaseErrorResponseException.php
<?php

namespace App\Exceptions;

use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use RuntimeException;

class BaseErrorResponseException extends RuntimeException implements Responsable
{
    /**
     * @var string
     */
    protected $message;

    /**
     * @var int
     */
    protected $statusCode;

    /**
     * @var string|null
     */
    protected $errorCode;

    /**
     * 初期エラーコード一覧
     * ステータスコードに紐付いた基本的なエラーコードで、アプリケーション固有のエラーコードは定義しない
     *
     * @var array
     */
    protected $defaultErrorCodes = [
        400 => 'bad_request',
        401 => 'unauthorized',
        403 => 'forbidden',
        404 => 'not_found',
        405 => 'method_not_allowed',
        422 => 'validation_error',
        500 => 'internal_server_error',
    ];

    /**
     * BaseErrorException constructor.
     *
     * @param string $message 簡易エラーメッセージ
     * @param int $statusCode ステータスコード
     */
    public function __construct(string $message = '', int $statusCode = 500)
    {
        $this->message = $message;
        $this->statusCode = $statusCode;
    }

    /**
     * @param string $message
     */
    public function setErrorMessage(string $message): void
    {
        $this->message = $message;
    }

    /**
     * @param int $statusCode
     */
    public function setStatusCode(int $statusCode): void
    {
        $this->statusCode = $statusCode;
    }

    /**
     * @param string $errorCode
     */
    public function setErrorCode(string $errorCode): void
    {
        $this->errorCode = $errorCode;
    }

    /**
     * @return string
     */
    public function getErrorMessage(): string
    {
        return $this->message;
    }

    /**
     * @return int
     */
    public function getStatusCode(): int
    {
        return $this->statusCode;
    }

    /**
     * @return null|string
     */
    public function getErrorCode(): ?string
    {
        return $this->errorCode;
    }

    public function toResponse($request)
    {
        return new JsonResponse(
            $this->getBasicResponse(),
            $this->getStatusCode()
        );
    }

    protected function getBasicResponse()
    {
        return [
            'message' => $this->getErrorMessage(),
            'code' => $this->getErrorCode() ?? $this->getDefaultErrorCode(),
        ];
    }

    protected function getDefaultErrorCode(): string
    {
        return $this->defaultErrorCodes[$this->getStatusCode()];
    }
}

このクラスのポイントは、Laravel 5.5から導入されたReponsableインターフェースを利用しているところです。このインターフェースを実装したクラスでは、レスポンスで返したい内容を toResponse で定義し、それを呼ぶと指定のレスポンスが返せる便利なものです。今回はエラーレスポンスとして利用するので RuntimeException を継承し、例外クラスとして利用するように工夫しています。

toResponse 内で message, code が返るように実装をします。設計方針としてはBaseErrorResponseException を根底クラスとし、アプリケーション固有のエラーコードやメッセージを指定したい場合は、このクラスを継承して利用する設計とします。

2. 根底クラスの継承クラスを作成する

これはあくまで例ですが、400 Bad Request で指定のエラーコードを返す例を実装します。

app/Exceptions/HogeErrorResponseException.php
<?php

namespace App\Exceptions;

class HogeErrorResponseException extends BaseErrorResponseException
{
    public function toResponse($request)
    {
        $this->setErrorMessage('This is hoge error message.');
        $this->setStatusCode(400);
        $this->setErrorCode('hoge_error');
        return parent::toResponse($request);
    }
}

3. エラーレスポンスを変更する

app/Exceptions/Handler.php
<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Response;
use Illuminate\Contracts\Support\Responsable;

class Handler extends ExceptionHandler
{
    /**
     * A list of the exception types that are not reported.
     *
     * @var array
     */
    protected $dontReport = [
        //
    ];

    /**
     * A list of the inputs that are never flashed for validation exceptions.
     *
     * @var array
     */
    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    /**
     * Report or log an exception.
     *
     * @param  \Exception  $exception
     * @return void
     */
    public function report(Exception $exception)
    {
        parent::report($exception);
    }

    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $exception
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $exception)
    {
        // Responsableインターフェースを継承したクラスはここでレスポンスを返す
        if ($exception instanceof Responsable) {
            return $exception->toResponse($request);
        }

        // HTTP系例外が発生した場合
        if ($this->isHttpException($exception)) {
            return $this->toResponse($request, $exception->getMessage(), $exception->getStatusCode());
        }

        // それ以外の場合は Internal Server Error とする
        return $this->toResponse($request, 'Internal Server Error', Response::HTTP_INTERNAL_SERVER_ERROR);
    }

    protected function toResponse($request, string $message, int $statusCode)
    {
         return (new BaseErrorResponseException($message, $statusCode))
            ->toResponse($request);
    }
}

例外は全て App\Exceptions\Handler クラスで処理されるので、ここを調整することでエラーレスポンス全体を変更することができます。今回はあくまで最低限の実装ですが、

  • Responsableを利用したアプリケーション固有のエラーレスポンスの処理
  • HTTP系レスポンス
  • それ以外の場合はInternal Server error

の3点を対応しました。本来であればバリデーションエラー、認証失敗時の対応も必要ですが、今回のを参考の拡張してみてください。

上記のように変更することで、HogeErrorResponseException の例外を送出をすると以下のレスポンスが返るようになりました。

{
    "title": "User's contract is expired.",
    "code": "contract_expired"
}

まとめ

エラーレスポンスは、マニュアル通り App\Exceptions\Handler を調整すれば変更することが分かりましたが、更に Responsable インターフェースを組み合わせることで汎用的な実装に拡張できました。BaseErrorResponseException を拡張していけば、ほとんどのエラーレスポンスに対応できます。ぜひご参考ください。

また今回触れませんでしたが、一般的なアプリケーションでは認証もあるのでそのエラーレスポンスも柔軟に対応していく必要があります。筆者はOAuth認証導入時にPassportを利用しましたが、残念ながらPassportのエラーレスポンスだけは変更できませんでした...。もし変更方法をご存知の方は教えて頂けると嬉しいです。

参考

hypermkt
PHPer, Vue.js, Laravel, Rails, React, React Native Wantedly: https://www.wantedly.com/users/137030
https://blog.hypermkt.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした