LaravelにおけるAPIレスポンスの一元化とリクエスト制限のミドルウェア活用

Laravelアプリケーション開発において、APIからのデータ返却形式の統一や、特定のAPIエンドポイントへのアクセス制限は重要な課題です。従来のコントローラ内で共通のトレイトを使用してこれらの処理を行う方法は、コードの見通しを悪くし、開発チーム内での認識の齟齬を生む可能性がありました。本記事では、この課題を解決するため、Laravelのルーティングミドルウェアを活用したアプローチを紹介します。これにより、ビジネスロジックとインフラストラクチャに関する処理を分離し、よりクリーンで保守性の高いコードベースを構築できます。

カスタム例外クラスの定義

特定のビジネスロジックエラーやシステムエラーを示すために、カスタム例外クラスを定義します。これらは、ミドルウェアが適切なエラーレスポンスを生成する際のトリガーとなります。

app/Exceptions/AuthorizationFailedException.php:

<?php

namespace App\Exceptions;

use Exception;
use Throwable;

class AuthorizationFailedException extends Exception implements Throwable
{
    /**
     * 承認失敗を示すカスタム例外。
     * @param string $message
     * @param int $code
     * @param Throwable|null $previous
     */
    public function __construct(string $message = '認証または認可に失敗しました。', int $code = 403, ?Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
}

app/Exceptions/TooManyRequestsException.php:

<?php

namespace App\Exceptions;

use Exception;
use Throwable;

class TooManyRequestsException extends Exception implements Throwable
{
    /**
     * リクエスト過多を示すカスタム例外。
     * @param string $message
     * @param int $code
     * @param Throwable|null $previous
     */
    public function __construct(string $message = 'リクエストが多すぎます。しばらくしてから再度お試しください。', int $code = 429, ?Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
}

APIレスポンス整形ミドルウェア

このミドルウェアは、コントローラの実行結果(成功時のデータ、または発生した例外)を捕捉し、統一されたJSONレスポンス形式に変換します。これにより、すべてのAPIエンドポイントで一貫したレスポンス構造が保証されます。

app/Http/Middleware/ApiResponseFormatter.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use App\Exceptions\AuthorizationFailedException;
use App\Exceptions\TooManyRequestsException;

class ApiResponseFormatter
{
    /**
     * リクエストを処理し、APIレスポンスの形式を統一します。
     *
     * @param Request $request
     * @param Closure $next
     * @return Response
     */
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);
        $originalContent = $response->getOriginalContent();

        // 応答コンテンツがカスタム例外インスタンスの場合、エラーとして処理
        if ($originalContent instanceof AuthorizationFailedException) {
            return response()->json([
                'status' => 'error',
                'code' => $originalContent->getCode(),
                'message' => $originalContent->getMessage(),
                'details' => $this->getExceptionDetails($originalContent, $request->get('debug') || config('app.debug'))
            ], $originalContent->getCode());
        } elseif ($originalContent instanceof TooManyRequestsException) {
            return response()->json([
                'status' => 'error',
                'code' => $originalContent->getCode(),
                'message' => $originalContent->getMessage(),
                'details' => $this->getExceptionDetails($originalContent, $request->get('debug') || config('app.debug'))
            ], $originalContent->getCode());
        } elseif ($originalContent instanceof Throwable) {
            // その他の一般的な例外の場合
            $statusCode = $response->getStatusCode();
            if ($statusCode < 400 || $statusCode >= 600) $statusCode = 500; // 範囲外のステータスコードを防ぐ
            
            return response()->json([
                'status' => 'error',
                'code' => $statusCode,
                'message' => $originalContent->getMessage() ?: 'サーバーで予期せぬエラーが発生しました。',
                'details' => $this->getExceptionDetails($originalContent, $request->get('debug') || config('app.debug'))
            ], $statusCode);
        } else {
            // 成功レスポンスの場合
            // 純粋なデータが返されたと仮定して統一形式にラップする
            return response()->json([
                'status' => 'success',
                'code' => 200,
                'message' => '操作は正常に完了しました。',
                'data' => $originalContent
            ]);
        }
    }

    /**
     * デバッグモードに応じて例外の詳細を返します。
     *
     * @param Throwable $e
     * @param bool $debugMode
     * @return string|array
     */
    protected function getExceptionDetails(Throwable $e, bool $debugMode): string|array
    {
        if ($debugMode) {
            return [
                'exception' => get_class($e),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'trace' => explode("\n", $e->getTraceAsString())
            ];
        }
        return $e->getMessage();
    }
}

APIリクエスト制限ミドルウェア

このミドルウェアは、Redisを使用して特定のIPアドレスからのリクエストレートを制限します。特に、ログイン失敗などの繰り返し試行を防ぐのに有効です。

app/Http/Middleware/ApiRateLimiter.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use App\Exceptions\TooManyRequestsException;

class ApiRateLimiter
{
    /**
     * 特定のIPアドレスからのリクエストレートを制限します。
     * ログイン失敗回数などのカウントに使用。
     *
     * @param Request $request
     * @param Closure $next
     * @return Response
     */
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $clientIp = $request->ip() === '127.0.0.1' ? $request->header('x-real-ip', $request->ip()) : $request->ip();
        $rateLimitKey = 'api_req_limit:' . sha1($request->getHost() . '|' . $clientIp); // ドメインとIPに基づくキー

        // レスポンスのオリジナルコンテンツが何らかの例外インスタンスの場合、エラーとしてカウント
        if ($response->getOriginalContent() instanceof Throwable) {
            $currentAttempts = (int) Redis::get($rateLimitKey);
            
            // Redisにキーが存在しない場合、初回または期限切れ後の失敗
            if (!Redis::exists($rateLimitKey)) {
                Redis::setex($rateLimitKey, 3600, 1); // 1時間有効、初回は1回とカウント
            } else {
                Redis::incr($rateLimitKey); // 既存の場合はインクリメント
            }

            $currentAttempts = (int) Redis::get($rateLimitKey); // 更新後の値を取得

            $maxAttempts = 5; // 最大試行回数
            if ($currentAttempts > $maxAttempts) {
                // TooManyRequestsExceptionをコンテンツとして設定し、次のミドルウェア(ApiResponseFormatter)で処理させる
                return $response->setContent(new TooManyRequestsException(
                    "短時間でのリクエストの試行回数が上限 ($maxAttempts 回) を超えました。", 429
                ));
            }
        } else {
            // 成功レスポンスの場合、レート制限カウンターをリセット
            Redis::del($rateLimitKey);
        }

        return $response;
    }
}

コントローラの簡素化

ミドルウェアがレスポンスの整形とレート制限を処理するため、コントローラはビジネスロジックに集中でき、コードが大幅に簡素化されます。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\AuthService; // 認証サービス(仮称)
use App\Exceptions\AuthorizationFailedException;
use Exception;

class AuthController extends Controller
{
    /**
     * ユーザーログイン処理。
     *
     * @param Request $request
     * @return array|object // 成功時はデータ、エラー時は例外をスロー
     * @throws AuthorizationFailedException|Exception
     */
    public function login(Request $request)
    {
        try {
            $username = $request->input('username');
            $password = $request->input('password');
            $captchaCode = $request->input('captcha_code'); // CAPTCHAコード

            // 入力値の簡易バリデーション
            if (empty($username) || empty($password)) {
                throw new Exception('ユーザー名またはパスワードが不足しています。', 400);
            }

            // 認証サービスを呼び出し、認証処理を実行
            $authenticationResult = AuthService::authenticateUser($username, $password, $captchaCode);

            // 認証成功時、結果データを直接返す(ApiResponseFormatterがJSONに変換)
            return ['user_info' => $authenticationResult, 'token' => 'sample_token'];

        } catch (AuthorizationFailedException $e) {
            // 承認失敗のカスタム例外はそのまま再スロー
            throw $e;
        } catch (Exception $e) {
            // その他の一般的なエラーは、新しい例外として再スローして詳細を隠蔽
            throw new Exception("ログイン処理中にエラーが発生しました。", 500, $e);
        }
    }
}

ミドルウェアの登録とルーティング

app/Http/Kernel.phpファイルにミドルウェアのエイリアスを登録します。

// app/Http/Kernel.php 内の $routeMiddleware 配列
protected $routeMiddleware = [
    // ... 既存のミドルウェア
    'api.format' => \App\Http\Middleware\ApiResponseFormatter::class,
    'api.limit' => \App\Http\Middleware\ApiRateLimiter::class,
];

ルート定義でミドルウェアを適用します。ミドルウェアの実行順序が非常に重要です。

use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;

// APIエンドポイントにミドルウェアを適用
Route::post('/api/login', [AuthController::class, 'login'])
    ->middleware(['api.limit', 'api.format']); // 順序に注意

このミドルウェアチェーンでは、api.limitが最初に実行され、コントローラやその後のミドルウェアの実行結果を検証します。もしレート制限に引っかかった場合、api.limitTooManyRequestsExceptionをレスポンスのコンテンツとして設定します。その後に実行されるapi.formatミドルウェアが、この例外を捕捉し、適切なエラーJSONレスポンスを生成します。この順序が逆になると、レート制限によるエラーが正しく処理されない可能性があります。

タグ: laravel Middleware API Response Handling Rate Limiting

7月14日 03:13 投稿