SwiftUI(Combine)✖️LaravelでAPI通信①（エラーレスポンスの統一化）

SwiftUI(Combine)、Laravel初心者。完全自分用の学習備忘録として残します。

フロントエンド側（アプリ側）にSwiftUI、バックエンド側にLaravelを使用したアプリを作成しています。

Laravel側でエラーが発生した際、`exception`の内容をそのままレスポンスとして返すのではなく、発生したエラーの種類や詳細を分かりやすく整理した形でレスポンスとして返したいと考えています。

現在、フロントエンドにはSwiftUIを用いたアプリのみを使用していますが、将来的にはアプリに加えてWeb画面（React）を使用する可能性もあります。そのため、各フロントエンド（アプリやWeb）で個別にエラー処理を実装するのではなく、Laravel側でエラーレスポンスを統一的に管理・返却する仕組みを構築して、エラーレスポンスを一元化させた方がいいのかなと考えています。そうすることで、アプリやWebのフロントエンド側での処理負担を軽減することができると思います。

以下の記事を参考にさせていただきました。

[[Laravel]Jsonレスポンスの形式を統一して、エラー処理をする方法 | ramble - ランブル -](https://ramble.impl.co.jp/859/)

### エラーレスポンスのフォーマットを作成

`ApiResponseServiceProvider.php` ファイルを作成します。

```php
php artisan make:provider ApiResponseServiceProvider
```

レスポンスマクロという機能を使用することで、成功時、失敗時のAPIのレスポンスデータをカスタマイズすることができます。

- app/Providers/ApiResponseServiceProvider.php
    
    ```php
    <?php
    
    namespace App\Providers;
    
    use Illuminate\Support\ServiceProvider;
    use Illuminate\Support\Facades\Response;
    
    class ApiResponseServiceProvider extends ServiceProvider
    {
        /**
         * Register services.
         */
        public function register(): void
        {
            //
        }
    
        /**
         * Bootstrap services.
         */
        public function boot(): void
        {
            // レスポンスマクロを用いて、Responseに'error'というカスタムメソッドを追加し、一貫したjson形式でエラーレスポンスを返す
            Response::macro('error', function ($status, $message, $detail = '') {
                return response()->json([
                    'code' => $status, // ステータスコード
                    'message' => $message, // エラーメッセージ
                    'detail' => $detail, // 開発者向け詳細メッセージ（SwiftUI側で本番ではなく、Develop環境時にUIAlertとして文言表示）
                ], $status);
            });
    
            // 今回こちらはあまり関係ない↓
            // レスポンスマクロを用いて、Responseに'success'というカスタムメソッドを追加し、一貫したjson形式でエラーレスポンスを返す
            // API通信成功時、フロント（SwiftUI側）に特に何も値を返却しない場合に使用
            Response::macro('success', function ($status, $message) {
                return response()->json([
                    'code' => $status,
                    'message' => $message,
                ], $status);
            });
        }
    }
    
    ```
    

次に作成したプロバイダーを`app/config/app.php`に登録します。

- app.php
    
    ```php
        'providers' => [
            // 省略
    
            /*
             * Application Service Providers...
             */
    　　　　  // 省略
            App\Providers\ResponseApiServiceProvider::class,
    ],
    
    ```
    

### カスタム例外クラスを作成

エラーの種類や状況を詳細に区別したいため、（作成しなくても良いですが）カスタム例外クラスを作成することで、特定のエラー状況（例: 認証エラー、特定の機能のビジネスロジックの失敗、など）を明確に区別できます。

まず、この後作成する**カスタム例外クラスが継承する基本の例外クラス**`class BaseException extends Exception {}`を作成します。カスタム例外クラスは、`class BaseException extends Exception {}` を継承することで、`$code, $message, $detail` にアクセスできるようになります。

- app/Exceptions/BaseException
    
    ```php
    <?php
    
    namespace App\Exceptions;
    
    use Exception;
    
    class BaseException extends Exception
    {
        // 開発者向けの詳細エラーメッセージ
        protected $detail;
        
        public function __construct($message = "", $code = 0, $detail = "")
        {
            // 親クラスプロパティの初期化
            // Exception(親クラス)には、getMessage()やgetCode()メソッドなど定義済み
            parent::__construct($message, $code);
            $this->detail = $detail;
        }
        
        public function getDetail()
        {
            return $this->detail;
        }
    }
    ```
    

カスタム例外クラスを作成していきます。（あくまで例として挙げる例外クラスのため、プロジェクトに合わせて自由な例外クラスを設定してください。）

以下の例外クラスは、SwiftUIアプリを”お試し”で使用しているユーザーに対して、特定の機能の利用が不可能であることを伝えるための例外クラスです。

- app/Exceptions/FeatureAccessDeniedInTrial
    
    ```php
    <?php
    
    namespace App\Exceptions;
    
    use Symfony\Component\HttpFoundation\Response;
    use App\Exceptions\BaseException;
    
    class FeatureAccessDeniedInTrial extends BaseException
    {
        public function __construct($message = "この機能はお試し期間中はご利用できません。アカウントを作成する必要があります。", $code = Response::HTTP_INTERNAL_SERVER_ERROR, $detail = "")
        {
            parent::__construct($message, $code, $detail);
        }
    }
    ```
    

お試し利用中のユーザーに対して利用制限をかける機能のAPIエンドポイントのController内に以下の処理のようにして、`FeatureAccessDeniedInTrial` をthrowします。

```php
 throw new FeatureAccessDeniedInTrial(detail: "Feature Access Denied In Trial");
```

以下の例外クラスは、YouTubeの動画や動画の字幕の「取得」「更新」「削除」、また、「字幕の翻訳」などの機能において発生したエラーをまとめる例外クラスです。

- app/Exceptions/VideoSubtitleException
    
    ```php
    <?php
    
    namespace App\Exceptions;
    
    use Symfony\Component\HttpFoundation\Response;
    use App\Exceptions\BaseException;
    
    class VideoSubtitleException extends BaseException
    {
        public function __construct($message = "", $code = Response::HTTP_INTERNAL_SERVER_ERROR, $detail = "")
        {
            parent::__construct($message, $code, $detail);
        }
    }
    ```
    

例えば、Controller内で以下のようにしてthrowさせるような感じでしょうか。

（詳細なコードは書いていませんが、必要に応じて、`try catch($e)`を行い、`detail: $e->getMessage()` というふうにしています。）

```php
throw new VideoSubtitleException(message: '字幕の取得に失敗しました。\nこの動画には字幕が含まれていない可能性があります。', detail: $e->getMessage());
throw new VideoSubtitleException(message: '保存した動画が見つかりませんでした。', detail: 'Video not Found / Failed to retrive some of the saved videos');
throw new VideoSubtitleException(message: 'データの更新に失敗しました。', detail: 'Video not Found / Failed to update data');
throw new VideoSubtitleException(message: '動画の削除に失敗しました。', detail: 'Video not Found / Failed to delete data');
throw new VideoSubtitleException(message: '字幕の翻訳に失敗しました。\n再度、お試しください。', detail: $e->getMessage());
```

必要に応じて、他のカスタム例外クラスも設定していきます。

### Handler.phpでエラーハンドリング

Laravelの例外は全て`app/Exceptions/Handler.php`で処理されます。

作成したカスタム例外クラス`FeatureAccessDeniedInTrial`, `VideoSubtitleException` を捕捉するだけではなく、`バリデーションエラー`や`HTTPエラー`のハンドリングにも対応していきます。それ以外のエラーは、不明なエラーとしてハンドリングしています。

詳しい解説はコード内に記述してあります。

- app/Exceptions/Handler.php
    
    ```php
    <?php
    
    namespace App\Exceptions;
    
    use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
    use Symfony\Component\HttpFoundation\Response;
    use Throwable;
    use App\Exceptions\BaseException;
    use Illuminate\Support\Facades\Log;
    
    class Handler extends ExceptionHandler
    {
        /**
         * The list of the inputs that are never flashed to the session on validation exceptions.
         *
         * @var array<int, string>
         */
        protected $dontFlash = [
            'current_password',
            'password',
            'password_confirmation',
        ];
    
        /**
         * Register the exception handling callbacks for the application.
         */
        public function register(): void
        {
            // reportableはログ出力系
            $this->reportable(function (Throwable $e) {
                //
            });
    
            // renderableはレンダリング系（jsonレスポンス作成/HTML返却）
            // $this->renderable(function ()) ...
        }
    
        
        // 例外発生時にその例外をどのようにレスポンスとして返すかを決定する
        public function render($request, $exception)
        {
            // apiルートで発生したエラーであるか否か
            if ($request->is('api/*')) {
                return $this->handleApiException($exception);
            }
    
            return parent::render($request, $exception);
        }
    
        // API例外を処理して適切なレスポンスを返す
        private function handleApiException($exception)
        {
    
            // BaseExceptionを継承するカスタム例外クラスを捕捉
            if ($exception instanceof BaseException) {
            Log::debug($exception);
            return $this->formatErrorResponse($exception);
            }
    
            // バリデーションエラー
            if ($exception instanceof \Illuminate\Validation\ValidationException) {
                return $this->validationErrorResponse($exception);
            }
            
            // HTTPエラー
            if ($this->isHttpException($exception)) {
                return $this->apiErrorResponse($exception);
            }
    
            // 不明なエラー
            return $this->defaultErrorResponse($exception);
        }
    
         // レスポンスマクロを使用した共通のエラーレスポンスフォーマット
        private function formatErrorResponse(BaseException $exception)
        {
            return response()->error(
                $exception->getCode(),
                $exception->getMessage(),
                $exception->getDetail()
            );
        }
    
        // バリデーションエラーのハンドラー
        private function validationErrorResponse(\Illuminate\Validation\ValidationException $exception)
        {
            // レスポンスマクロを使用した共通のエラーレスポンスフォーマット
            return response()->error(Response::HTTP_BAD_REQUEST, '不正なリクエストです。', $exception->errors());
        }
    
        // HTTPエラーのハンドラー
        private function apiErrorResponse($exception)
        {
            $statusCode = $exception->getStatusCode();
            $detail = $exception->getMessage();
            
            $errorMessages = [
                Response::HTTP_BAD_REQUEST => '不正なリクエストです。',
                Response::HTTP_UNAUTHORIZED => '認証情報が正しくありません。',
                Response::HTTP_FORBIDDEN => 'アクセスエラーが発生しました。',
                Response::HTTP_NOT_FOUND => '存在しないURLです。',
                Response::HTTP_METHOD_NOT_ALLOWED => '無効なリクエストです。',
                Response::HTTP_NOT_ACCEPTABLE => '受付不可能なリクエスト値です。',
                Response::HTTP_REQUEST_TIMEOUT => 'リクエストがタイムアウトしました。',
            ];
    
            $message = $errorMessages[$statusCode] ?? 'サーバー側でエラーが発生しました。';
    
            if ($statusCode >= 500 && $statusCode <= 599) {
                $message = 'サーバー側でエラーが発生しました。';
            }
            
            // レスポンスマクロを使用した共通のエラーレスポンスフォーマット
            return response()->error($statusCode, $message, $detail);
        }
    
        // 未対応例外のデフォルトレスポンス
        private function defaultErrorResponse($exception)
        {
            // レスポンスマクロを使用した共通のエラーレスポンスフォーマット
            return response()->error(
                Response::HTTP_INTERNAL_SERVER_ERROR,
                'サーバー側で不明なエラーが発生しました。',
                ['message' => $exception->getMessage()]
            );
        }
    }
    
    ```