Laravelでopenapi-validatorを使ってスキーマ駆動開発をしてみよう
目次
はじめに
バックエンドエンジニアのまさとです。コマンドだけで開発を進めるタイプです。よろしくお願いします。
今回は Laravel で実装した API に openapi-validator を適用し、OpenAPI ドキュメント (Swagger YAML ファイル) と実装に乖離がないかを検証するミドルウェアを実装してみたので紹介します。
PoC ということもあり、Docker でのローカル環境 (PHP/Nginx/MySQL/DynamoDB-local) での実装です。実装部分で不明瞭な点が多数見られますが、色々あったんだなと思って温かい目で見守ってください(笑)。また本ミドルウェア実装で使用した Swagger も共有したかったのですが、都合的にできなかったのでご容赦ください。
1. openapi-validator ってなに?
openapi-validator ってなんなの?という方も多いと思います。自分も案件で使うまで存在を知りませんでした。
openapi-validator とは、Swagger ファイルでの API 定義と実際に実装した API に違いがないかを検証するアプリケーションです。Swagger を正としたスキーマ駆動開発を行うために使うことが多いです。PHP (特に Laravel) ではそれをラップした laravel-openapi-validator を利用できます。また TypeScript (express など) では express-openapi-validator も存在し、言語ごとにスキーマ駆動開発を行うことができます。
今回は API 実装に Laravel を利用するため、前者の laravel-openapi-validator を使った事例を紹介します。
2. モチベーション
現在、ソニックムーブのフロントエンドユニットでは、TypeScript の型生成にバックエンドのユニットが作成した Swagger ファイルを利用しています。Swagger という資源を有効活用したいい試みですね。
ただ、バックエンド担当側が実装面に置いて Swagger を活用しきれていない部分が見受けられました。自分が所属するソリューションユニットでは、受託案件のバックエンド・インフラ開発をメイン業務としていますが、そうしたバックエンドでの Swagger 利用について、常々解決するべき課題の一つとして認識していました。
そこでソリューションユニットとして、この問題をコード品質を高める施策である「標準化施策」に位置付け、受託開発のメインフレームワークである Laravel でのバックエンド適用検証・動作テストを進めてきました。
その中で自分は、「API 開発をサポートする Swagger の利用と運用方法」について検討しました。色々調べる中で openapi-validator を使った API ドキュメントと実装との検証に目をつけ、それを使ったPoCで色々やったことを本記事では紹介したいと思います。
言いたいこと
この記事でわかることは以下の通りです。
- S3 に保存した Swagger (yml ファイル) の内容を取得して、openapi-validator を実行・API の検証を実施できた
- openapi-validator 初期化処理を簡略化するため、DynamoDB をキャッシュテーブルとして使用した
- ミドルウェアにすることで全ての API について適用できるようにした
- API ごとに読み込む Swagger ファイルを限定できるように設定した (ex. 管理画面 API では admin.yml をロードしたいとか)
- 環境ごとにミドルウェア適用から除外できるような設定にした (ex. testing 環境では適用したいけど、dev/stg/prd では使いたくないとか)
- Swagger の requestBody パラメーターのデータ型や長さなどを厳密に定義しない方が無難
前提条件・環境情報
本記事やミドルウェア開発フローに関する前提条件等を以下にまとめています。
1. 前提条件
- 本記事では 使用する Docker および AWS リソースに関する説明を省略する (機会があれば別記事にしようと思ってます)
- Docker でのローカル環境 (PHP/Nginx/MySQL/DynamoDB-local の 4 コンテナ構成) はすでに構築済み
- Swagger は S3 バケットに「s3://sample-bucket/swagger/index.yml」で格納している
- ミドルウェア動作検証で使用する API は、JWT Auth のログイン処理 (POST) ですでに実装したものとする
- API 実行では、API について Feature テストを実装し、PHPUnit を用いて実行する
- Swagger ロード時のキャッシュ処理では、DynamoDB (今回は DynamoDB-local コンテナイメージ) を使用する
2. 環境情報
- PHP: 8.2.x (Laravel10.x)、バックエンド主要フレームワーク
- composer: 2.5.x、PHP パッケージ管理
- Docker: 20.10.24、ローカル環境構築
- Docker-Compose: 2.17.2、同上
- einar-hansen/laravel-psr-6-cache : 1.0.1、PSR6 が持つキャッシュ処理を負荷軽減で使用する (自分が調べた時はpsr-7がなかったので、リンクにpsr-7を貼っています。記事ではpsr-6を使ってますが、そこをpsr-7として変えてください)
- kirschbaum-development/laravel-openapi-validator : 0.3.1、openapi-validator を Laravel 用にラップしたもの
実際の開発フロー
1. DynamoDB キャッシュテーブルを作成
DynamoDB-local コンテナで testing-cache というキャッシュテーブルを作成します。
後述するミドルウェア実装でも紹介しますが、Swagger を初めて読み込んだ時に、その内容を一時的にそのキャッシュテーブルに保存します。
2. composer 依存解決
ミドルウェア実装に必要な依存関係をインストールします。
今回は Docker で作業を行っているので、Docker コマンドを使っています。PHP コンテナ名は適当なので、各自 Dockerfile や docker-compose.yml で任意の名前に設定してください。
※ psr-6でインストールしてますが、psr-7で大丈夫です。
1 2 |
docker exec -it <PHPコンテナ名> composer require --dev einar-hansen/laravel-psr-6-cache docker exec -it <PHPコンテナ名> composer require --dev kirschbaum-development/laravel-openapi-validator |
3. Laravel 設定ファイルの修正
ここからは Laravel 側の設定に入ります。編集するファイルは以下の通りです。
- .env.testing: PHPUnit 実行時に適用するテスト用環境変数を定義 (API を Postman や Stoplight Studio などで叩く場合は.env.local で定義しても問題ありません)
- config/filesystems.php: Swagger を格納した S3 バケットとの連携設定を記載
- config/cache.php: DynamoDB-local と連携するためのキャッシュ設定を記載
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
... # キャッシュ・セッションドライバーをデフォルトのfileからdynamodbに変更する BROADCAST_DRIVER=log CACHE_DRIVER=dynamodb FILESYSTEM_DISK=local QUEUE_CONNECTION=sync SESSION_DRIVER=dynamodb SESSION_LIFETIME=120 ... # DynamoDBでキャッシュ・セッション管理するための設定を追加 # アクセスキーID/シークレットキーID/リージョンはaws-cliコンテナで定義したものを使用 DYNAMODB_ACCESS_KEY_ID=local DYNAMODB_SECRET_ACCESS_KEY=local DYNAMODB_CACHE_TABLE=testing-cache DYNAMODB_ENDPOINT=http://dynamodb:6379/ DYNAMODB_DEFAULT_REGION=ap-northeast-1 ... # SwaggerドキュメントがあるS3情報を追加する # swaggerのymlファイルパス: S3://${AWS_DOCUMENT_BUCKET}/${AWS_DOC_DIRPATH}/xxx.ymlを想定 AWS_DOCUMENT_BUCKET=sample-bucket AWS_DOC_DIRPATH="swagger" ... |
.env.testing では、キャッシュドライバー・DynamoDB-local コンテナ接続情報、読み込む Swagger ドキュメントの S3 バケットに関する変数を記載しています。
キャッシュでは後述する config/cache.php で dynamodb というキャッシュドライバーを定義するため、CACHE_DRIVER=dynamodb という感じで設定しています。それ以外は特にいじる必要はありません。
次に DynamoDB-local コンテナ連携のため、credential 情報を追記していきます。credential 情報は docker-compose.yml での定義と同じものを使用します。
またキャッシュテーブルには、1 で作成した testing-cache を、エンドポイントには「http://<DynamoDB-local コンテナサービス名>:<ポート番号>/」で設定します。
最後に Swagger を格納した S3 バケットについて、今回は「s3://sample-bucket/swagger/index.yml」なので、上記のように設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<?php return [ ... 'disks' => [ ... # 以下を追加する 's3_documents' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_DOCUMENT_BUCKET'), 'doc_dirpath' => env('AWS_DOC_DIRPATH'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, // openapi-validatorミドルウェアを適用しない環境名を追加する 'excludes' => ['local', 'dev', 'stg', 'prd'], ], ], ... ]; |
config/filesystems.php では、デフォルトで s3 というディスクが用意されています。
ここではそれをいじらないようコピーし、s3_documents ディスクを新たに定義しています。s3 ディスクとの違いとして、doc_dirpath と excludes があります。前者は.env.testing で定義した Swagger を格納しているディレクトリ名、後者は.env.xxxxx に対応した APP_ENV 配列です。
excludes 配列の詳細な説明は、後述するミドルウェア実装で紹介します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php use Illuminate\Support\Str; return [ ... 'stores' => [ ... # dynamodbストア設定を.env.testingで追加した変数を読むように書き換える 'dynamodb' => [ 'driver' => 'dynamodb', 'key' => env('DYNAMODB_ACCESS_KEY_ID', ''), 'secret' => env('DYNAMODB_SECRET_ACCESS_KEY', ''), 'region' => env('DYNAMODB_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 'endpoint' => env('DYNAMODB_ENDPOINT', ''), ], ... ], ... ]; |
config/cache.php も filesystems.php と同様、デフォルトで dynamodb ドライバーが定義されています。今回は環境変数の違いでキャッシュテーブルを操作するため、そのデフォルト dynamodb ドライバーを上記のように修正します。
以上 3 つのファイルについて編集が完了したら、以下のコマンドで念の為に設定ファイルのキャッシュを消しておきます。
1 |
docker exec -it <PHPコンテナ名> php artisan config:clear |
4. ミドルウェア実装
ここから本格的に openapi-validator を使用したミドルウェア実装について紹介します。今回は OpenApiValidator.php というミドルウェアを実装するため、artisan コマンドでそのスタブを生成しておきます。
1 |
docker exec -it <PHPコンテナ名> php artisan make:middleware OpenApiValidator |
生成したスタブを以下のように編集していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
<?php namespace App\Http\Middleware; use Closure; use EinarHansen\Cache\CacheItemPool; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; use Kirschbaum\OpenApiValidator\ValidatesOpenApiSpec; use League\OpenAPIValidation\PSR7\OperationAddress; use League\OpenAPIValidation\PSR7\ValidatorBuilder; use Nyholm\Psr7\Factory\Psr17Factory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\Response; class OpenApiValidator { use ValidatesOpenApiSpec; private readonly string $dirpath; private readonly array $exclusiveEnvs; public function __construct() { $this->dirpath = config( 'filesystems.disks.s3_documents.doc_dirpath' ); $this->exclusiveEnvs = config( 'filesystems.disks.s3_documents.excludes' ); } public function handle( Request $request, Closure $next, string $apiDocName = '' ): Response { // 除外リストに含まれる環境ならミドルウェアを使わずに処理 if (in_array(App::environment(), $this->exclusiveEnvs)) { return $next($request); } // 指定したSwaggerドキュメントがS3にない場合は適用せずに処理 $file = $this->existSwaggerDocs($apiDocName); if (is_null($file)) { return $next($request); } // PSR-7に準拠したHTTPオブジェクトを作成 $psr17Factory = new Psr17Factory(); $psr17HttpFactory = new PsrHttpFactory( $psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory ); if (!Cache::has("openApiValidator_{$apiDocName}")) { // バリデーターの準備 $validatorBuilder = (new ValidatorBuilder()) ->fromYaml($file) ->setCache(new CacheItemPool(Cache::store('dynamodb')), 3600) ->overrideCacheKey("openApiValidator_{$apiDocName}"); } else { // キャッシュからバリデーターオブジェクトを取り出す $validatorBuilder = Cache::get("openApiValidator_{$apiDocName}"); } try { // リクエストの検証 $psrRequest = $psr17HttpFactory->createRequest($request); $requestValidation = $validatorBuilder->getRequestValidator(); $requestValidation->validate($psrRequest); // レスポンスの検証 $response = $next($request); $psr7Response = $psr17HttpFactory->createResponse($response); $responseValidation = $validatorBuilder->getResponseValidator(); $responseValidation->validate( new OperationAddress( $request->getPathInfo(), strtolower($request->getMethod()) ), $psr7Response ); } catch (Exception $e) { // レスポンスコードは422 return response()->json( data: [ 'message' => 'openapi-validatorエラーです。', 'error' => $e->getMessage(), ], status: Response::HTTP_UNPROCESSABLE_ENTITY, ); } return $response; } private function existSwaggerDocs( string $apiDocName ): ?string { // そのS3ディレクトリにあるファイルパス配列を取得 $files = Storage::disk('s3_documents')->files($this->dirpath); foreach ($files as $filepath) { $fileInfo = pathinfo($filepath); if (isset($fileInfo['basename']) && $fileInfo['basename'] === $apiDocName) { return Storage::disk('s3_documents')->get($filepath); } } return null; } } |
長くて吐きそうではありますが、実装の要点を説明していきます。
ミドルウェアのコンストラクタでは、config/filesystems.php で定義した doc_dirpath と excludes を取得します。
前者は S3 バケットにアクセスして特定の Swagger ファイルがあるかを判断するのに使用します。後者は APP_ENV がこの配列に含まれている場合に限り、ミドルウェアを適用しないようにするために設定しています。
今回は.env.testing (APP_ENV=testing) の環境でのみミドルウェアを適用する想定です。
次にミドルウェア実行部分 (handle メソッド) では、先の適用除外処理を先に行い、適用する場合に dynamodb キャッシュドライバーを参照して、すでに Swagger ファイル内容のキャッシュがあるかどうかを判定します。
ある場合はそのキャッシュから Swagger ファイルの内容をロードし、ない場合は新たに openApiValidator\_<Swagger ファイル名>で内容をキャッシュ・利用します。
この処理があると、別の API にミドルウェアを適用した際に再度 Swagger ファイルをロードする必要がなくなり、時短ができます。
最後に try-cache 部分では、ロードした Swagger ファイルと API とでリクエストボディ・レスポンスが一致しているかを検証します。仮に検証が失敗した場合は、「Swagger と API 実装が一致していない」ということになり、422 ステータスでエラーが return されます (ステータスコードはノリで決めました笑) 。
また handle メソッドでは、第三引数に apiDocName を要求しています。
これはミドルウェア適用時に検証で使用したい Swagger ファイルを選ぶために設定しています。API によっては別の Swagger ファイルで定義を書いていることもあるため、適用するファイル名を別途指定できるようになっています。
今回の場合は API 定義を全て index.yml に記載しているため、ここは index.yml となります。
5. ミドルウェアの適用
ミドルウェアを実装したはいいものの、このままだと API に適用できないので適用させていきます。Laravel ではミドルウェアのエイリアスを登録して呼び出す機能があるため、app/Http/Kernel.php で実装したミドルウェアエイリアスを登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<?php namespace App\Http; use App\Http\Middleware\OpenApiValidator; use Illuminate\Foundation\Http\Kernel as HttpKernel; class Kernel extends HttpKernel { ... protected $middlewareAliases = [ ... 'openapi-validator' => OpenApiValidator::class, ]; } |
次に JWT Auth ログイン処理 (エンドポイント: /user/auth/login) にミドルウェアを適用します。
今回は app/Http/Controllers/User/AuthController.php をあらかじめ実装しており、以下のようなリクエストボディ・レスポンスを期待しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# リクエストボティ { "email": "sample@example.com", "password": "SampleSamplePassword00" } # APIレスポンス (200) { "accessToken": "xxxxxxxxxxxxxxxxxx", "tokenType": "Bearer", "expiredIn": 86400 } # APIレスポンス (401) { "message": "メールアドレスまたは、パスワードが正しくありません。" } # APIレスポンス (404) { "message": "該当するログインユーザーが見つかりません。" } |
ミドルウェアの適用は、コントローラー内でもいいんですが諸事情により、ルーティングファイルで行います。
API によってはまだ Swagger 検証を行いたくない場合があると思います。なので今回は API 全てのエンドポイントを管理している routes/User/auth.php で API ごとに個別に適用するようにしています。
こうすることで、適用したくない API については middleware メソッドをコメントアウトすることで対応できます。適用する Swagger ファイルを<ミドルウェア prefix>:<適用したい Swagger ファイル名>で指定します。
また前提として、今回使用する API ルーティングは app/Providers/RouteServiceProvider.php に反映済みとします。
1 2 3 4 5 6 7 8 9 10 11 12 |
<?php use App\Http\Controllers\User\AuthController; use Illuminate\Support\Facades\Route; // ユーザー側ログイン Route::controller(AuthController::class)->group(function () { Route::post('/auth/login', 'login') ->middleware('openapi-validator:index.yml') ->name('user.auth.login'); ... }); |
6. ミドルウェア動作確認
適用も終わったので、実際にミドルウェアを適用した API を実行してみたいと思います。先述の通り今回は Feature テスト経由で API を実行します。
単純に動作を確認するだけなら、内容は使用する Feature テストは API コントローラーが呼ばれていればなんでも構いません。なんなら assertTrue(true)みたいなテストでもいいです。
今回はテストは作ったという前提で進めていきます。Laravel では以下のようにテストを実行できますが、コマンドはどっちでも構いません。
1 2 3 |
# PHPUnitでテストを実行する場合は以下のコマンドをやる (artisanを使うかPHPUnitを直接使うかどちらでも) docker exec -it <PHPコンテナ名> php artisan test docker exec -it <PHPコンテナ名> vendor/bin/phpunit |
結果・考察
結論から言うと、正常系のテストケースではリクエストボディ・レスポンスともにミドルウェアではエラーが確認されませんでした。つまり「Swagger と API 実装は全く定義通り」ということになります。
しかしコケた場合が存在します。それがリクエストボディの FormValidation に関する異常系テストケースです。
テストケース内で API レスポンスを dump してみると、以下のようなレスポンスを返すことがわかりました。
1 2 3 4 |
{ "message": "openapi-validatorエラーです。", "error": "Body does not match schema for content-type "application/json" for Request [post /auth/login]", } |
上記のエラーでは「Swagger で定義したリクエストボディと実際に API に入力したリクエストボディが違う」と言っています。自分が確認したところ Swagger での実装も API 実装も特に問題がないように見えました。正常系との違いは一体なんなんでしょうか?
原因として、「ミドルウェアが API の FormValidation より先に呼ばれていること」と「Swagger でのリクエストボディ定義が厳密すぎる」ことが挙げられます。
まず前者については当たり前ではありますが、ミドルウェアは API コントローラーの処理の前後に別の処理を挟むための機構です。なので、その API の FormValidation が実行される前に Swagger 検証で失敗してしまうと、FormValidation が実行できずに処理が止まってしまいます。
これが openapi-validator エラーがテストエラーより先に現れた理由です。
次に後者ですが、これは Swagger でのリクエストボディで email というパラメーターが「メールアドレス形式の文字列」を、password というパラメーターが「12 文字から 32 文字の文字列」受け取るようデータフォーマットを指定していたことに起因しています。
以下に Stoplight Studio での設定画面を添付します。
今回エラーが発生した Feature テストの異常系ケースでは、email に「メールアドレス形式ではない文字列」を入力していました。
本来は API が FormValidation エラーレスポンスを返し、それが Swagger と一致すると想定していましたが、先に Swagger のリクエストボディ定義でエラーとなりました。
先述の通り、ミドルウェアは API 処理の前に呼ばれるため、実行された API には Swagger 定義にそぐわないリクエストパラメーターが入力され、openapi-validator はそれを間違いだと判断したことになります。要は FormValidation 実行前に openapi-validator から「email メールアドレスじゃない」と怒られた感じになります。
password パラメーターの場合も同様で、テストケースで「2文字の文字列」を指定していたため、「password の長さが違う」と怒られた感じです。
これを防ぐには、Swagger でのリクエストボディ定義で、データフォーマットなどを厳密に指定しないようにする必要があります。
例えば email に関しては、単純に文字列型のパラメーターであることだけを定義しておくみたいな感じです。Swagger 定義として、厳密じゃないのはいいと思いませんが、openapi-validator を使う場合はそこら辺を妥協する必要もあるかもしれません。
そこを厳密に定義しないことがフロントエンド側で型生成を行うときに影響しないかも検証してみましたが、特に影響はないことがわかりました。
最後に
以上で openapi-validator 紹介を終わります。長々とありがとうございました。
スキーマ駆動開発は API 開発において、実装の完全性を担保するのに一役買ってくれる開発手法だと思っています。Swagger を正とすることで、バックエンド/フロントエンド間での API 繋ぎこみなどで実装差異が発生しづらくなると期待しています。
今後も今回の内容をブラッシュアップして開発していきたいと思います!