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

## はじめに

この記事は[Laravel Advent Calendar 2018](https://qiita.com/advent-calendar/2018/laravel) 4日目の記事です。

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

### 環境
* PHP7.2
* Laravel 5.7.15

## 問題提起

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

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

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

## 解決方法

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

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

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

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

## 実装手順

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

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

```php: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から導入](https://laravel-news.com/laravel-5-5-responsable)された[Reponsableインターフェース](https://github.com/illuminate/contracts/blob/5.7/Support/Responsable.php)を利用しているところです。このインターフェースを実装したクラスでは、レスポンスで返したい内容を `toResponse` で定義し、それを呼ぶと指定のレスポンスが返せる便利なものです。今回はエラーレスポンスとして利用するので `RuntimeException` を継承し、例外クラスとして利用するように工夫しています。

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

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

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

```php: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. エラーレスポンスを変更する

```php: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` の例外を送出をすると以下のレスポンスが返るようになりました。

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

## まとめ

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

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

## 参考
* [WebAPIでエラーをどう表現すべき？15のサービスを調査してみた \- Qiita](https://qiita.com/suin/items/f7ac4de914e9f3f35884)