🛡

Laravel OpenAPIを使ったリクエストパラメータのバリデーションで爆速開発する

2023/01/03に公開

💁‍♂‍はじめに

こんにちはEGSTOCKの日下繕章です
今回Laravel環境にて爆速開発を目指す上でOpenAPI定義に従ったバリデーション機能が
欲しかったためOpenAPI PSR-7 Message Validatorパッケージの検証をしてみた
https://github.com/thephpleague/openapi-psr7-validator

実際は上記をラップしたLaravel OpenAPI Validatorで動作確認をしていく
https://github.com/kirschbaum-development/laravel-openapi-validator

👉OpenAPI PSR-7 Message Validatorとは

OpenAPI PSR-7 Message Validatorは、OpenAPI(またはSwagger)スキーマを使用して、HTTPメッセージ(リクエストまたはレスポンス)の構造と内容を検証するPHPライブラリ

これを使用すると、WebAPIのエンドポイントに送信されるHTTPリクエスト/レスポンスが、OpenAPIスキーマで定義されたものと一致するかを確認することが可能、これにより、Web APIの実装とスキーマの不整合を防ぐことができる

またOpenAPI仕様の解析時間を短縮するためのキャッシュ層(PSR-6インターフェースに基づく)が組み込まれている

検証可能なフォーマットはこちらを参照

⭐メリット

  • 問題の早期発見。開発プロセスの早い段階で仕様を検証することで、問題が深刻化する前に特定し、修正することが可能
  • 相互運用性の向上。PSR-7仕様に準拠したバリデータを使用することで、同仕様に準拠した他のライブラリやフレームワークとシームレスに動作させることができる
  • セキュリティの強化。バリデータは、コードが正しいフォーマットに従ったHTTPメッセージで動作していることを確認するのに役立ちます。これにより、インジェクション攻撃などのセキュリティ上の脆弱性を防ぐことが可能
  • 保守性の向上。HTTPメッセージを扱うための標準的なインタフェースを使用することで、コードの理解や保守が容易になる

⛅デメリット

  • PSR-7仕様とバリデータの使い方を学ぶのに時間がかかる可能性がある
  • PSR-7を使用していないコードベースを使用している場合、バリデータを使用するためにコードをPSR-7仕様に準拠するようにリファクタリングする必要がある
  • パフォーマンスのオーバーヘッドの可能性。バリデータを使用すると、コードに追加の処理レイヤーが追加され、パフォーマンスに影響を及ぼす可能性がある

🔧準備

検証環境

php8.0
Laravel9
※前提としてLaravelが動作する状態であること

今回はサンプルの定義としてSwagger Petstore swagger.yamlを使用

1) 必要パッケージをインストール

必要なパッケージをまるっと手に入れたいので今回はLaravel OpenAPI Validatorをインストール

composer require kirschbaum-development/laravel-openapi-validator

負荷軽減のためPSR-6に準拠したキャッシュ操作の実装が欲しいのでlaravel-psr-6-cacheをインストール

composer require einar-hansen/laravel-psr-6-cache

2) OpenApi定義を設置

Swagger Petstore swagger.yamlstorage直下に配置しておく

3) バリデーションを行なうMiddlewareを設置

新規追加

app/Http/Middleware/ApiValidation.php
namespace App\Http\Middleware;

use EinarHansen\Cache\CacheItemPool;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use League\OpenAPIValidation\PSR7\OperationAddress;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

class ApiValidation
{
    public function handle(Request $request, \Closure $next)
    {
        $psr17Factory = new Psr17Factory();
        $psr17HttpFactory = new PsrHttpFactory(
            $psr17Factory,
            $psr17Factory,
            $psr17Factory,
            $psr17Factory
        );
        $psrRequest = $psr17HttpFactory->createRequest($request);

	// 設置したOpenApi定義ファイルを設定
        $validatorBuilder = (new ValidatorBuilder())->fromYamlFile(base_path('storage/swagger.yaml'));
	// OpenApi定義ファイルの読み込み結果のキャッシュ先を設定
        $validatorBuilder->setCache(
            new CacheItemPool(Cache::store('file'))
        );

	// リクエストのバリデーション
        $requestValidator = $validatorBuilder->getRequestValidator();
        $requestValidator->validate($psrRequest);

        $response = $next($request);

	// レスポンスのバリデーション
        $psr7Response = $psr17HttpFactory->createResponse($response);
        $responseValidator = $validatorBuilder->getResponseValidator();
        $responseValidator->validate(
            new OperationAddress(
                $request->getPathInfo(),
                strtolower($request->getMethod())
            ),
            $psr7Response
        );

        return $response;
    }
}

apiにのみバリデーションを行なうように設定

app/Http/Kernel.php
        'api' => [
            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
+            \App\Http\Middleware\ApiValidation::class,
        ],

4) 検証のためのサンプルAPIを設置

route定義追加

routes/api.php
+Route::get('/store/order/{orderId}', function () {
+    return response()->json();
+});

🏃‍♂‍動かしてみる

GET/store/order/{orderId}APIをphpunitから動かして、リクエストを検証させてみる

定義

1) テストを追加

新規追加

tests/Feature/OrderTest.php
<?php
namespace Tests\Feature;

use Tests\TestCase;

class OrderTest extends TestCase
{
    public function test_create_pathパラメータが数値の場合はステータスコード200が返却されること(): void
    {
        $response = $this->getJson('http://localhost/api/store/order/1');
        $response->assertOk();
    }

    public function test_create_pathパラメータが数値以外の場合はステータスコード500が返却されること(): void
    {
        $response = $this->getJson('http://localhost/api/store/order/a');
        $response->assertStatus(500);
    }
}

2) 実行

追加したテストを実行

php artisan test tests/Feature/OrderTest.php 

ログを覗くと、エラー箇所についての詳細が出力されている
👉ボリューミーなので本番等では必要な部分のみにカットするのが現実的かも

storage/logs/laravel.log
[2023-01-02 10:08:43] testing.ERROR: Value "a" for parameter "orderId" is invalid for Request [get /store/order/{orderId}] {"exception":"[object] (League\\OpenAPIValidation\\PSR7\\Exception\\Validation\\InvalidPath(code: 0): Value \"a\" for parameter \"orderId\" is invalid for Request [get /store/order/{orderId}] at /data/vendor/league/openapi-psr7-validator/src/PSR7/Exception/Validation/AddressValidationFailed.php:28)[previous exception] [object] (League\\OpenAPIValidation\\PSR7\\Exception\\Validation\\InvalidParameter(code: 0): Parameter 'orderId' has invalid value 'a' at /data/vendor/league/openapi-psr7-validator/src/PSR7/Exception/Validation/InvalidParameter.php:38)[previous exception] [object] (League\\OpenAPIValidation\\Schema\\Exception\\TypeMismatch(code: 0): Value expected to be 'integer', 'string' given. at /data/vendor/league/openapi-psr7-validator/src/Schema/Exception/TypeMismatch.php:20)

🙋‍♂‍感想

実際に動作させてみた感じでは下記あたりが多少気になった印象

  • CacheItemPoolにRedisなどを指定した場合に定義変更後にキャッシュクリアが必要
    👉 local/test環境ではキャッシュは無効にしておくなどしておくと開発しやすいかも
  • OpenAPI定義違反の場合に出力されるエラーログがボリューミー
    👉 validate()メソッドをtry-cacheで制御することで抑制は可能そう
  • 慣れるまでエラーメッセージから問題箇所が特定しずらい
    👉 慣れましょう

気になる点はあるが、何よりそれ以上に受けれる恩恵(メリット参照)の方が上回ってると言えるのではないか
何よりリクエストパラメータのバリデーションの実装&保守工数を大幅に削減できる点は開発者として嬉しすぎる

実際に導入した場合の負荷についても次回記事にて検証予定

EGSTOCK,Inc.

Discussion