LaravelアプリケーションのAPIがSwagger/OpenAPIドキュメントに準拠していることを透過的にテストする
こんにちは、でぃーほりです。
Laravelアプリケーション開発において、
「API実装がSwagger/OpenAPIドキュメントに準拠していることを透過的にテストする」
仕組みを構築する機会があったので、背景・モチベーションから順を追ってご紹介します。
対象読者
- バックエンドAPI開発に携わっている
- API仕様の文書化にSwagger/OASを使用している
- API仕様と実装が乖離して困っている
背景
Swagger/OpenAPIドキュメント
Swagger/OASとはAPI仕様の文書化標準です。HTTPリクエスト/レスポンスの形式を、人間とコンピュータの両者が理解できる形で文書化できます。
OAS(OpenAPI Specification)はSwaggerの後発にあたります。
(バージョン2までがSwagger、バージョン3からはOAS)
仕様がさき、実装があと
巷では、APIバックエンドのソースコードからOpenAPIドキュメントを生成するやり方がよく見られます。 この方法ではバックエンドの実装が正となり、仕様が追従します。 概念クラス図で表すと次のようになります。
いっぽう、OpenAPIドキュメントを先に作り、実装が従うという形をとることもできます。 概念クラス図で表すと次のようになります。
本記事では後者の方法に焦点を当てます。
OpenAPIドキュメントでAPI実装を検証する
上記の概念クラス図では、OpenAPIドキュメントをinterface、APIバックエンドをclassで表しました。
オブジェクト指向言語には、「classがinterfaceを正しく実装しなければコンパイルを通さない」といった仕組みが備わっています。
同様に、「APIバックエンドがOpenAPIドキュメントを正しく実装しなければテストを落とす」といった仕組みを作ることができます。
OpenAPIドキュメントによるAPI実装の検証を行うためのPHPライブラリには次のようなものがあります。
- wakeonweb/swagger (https://github.com/WakeOnWeb/swagger)
Swagger v2用 - league/openapi-psr7-validator (https://github.com/thephpleague/openapi-psr7-validator)
OAS(Swagger v3)用
透過的に検証したい
商用環境でエラーを出したいわけではないので、自動テスト環境での検証に絞って話を進めます。
OpenAPIドキュメントによるAPIバックエンドの検証は、オブジェクト指向言語のinterface/classと異なり、強制力がありません。自分で検証を行う必要があります。
しかしながら、テストコード中でいちいちアサートを書くのは面倒で、抜け漏れも発生します。
テストコード中でAPIをコールするたびに透過的に検証されるのが理想形です。
LaravelのFeatureテストで、API実装がOpenAPIドキュメントに準拠していることを透過的にテストする仕組みを作った
ようやく本題です。
テストコード側
public function setUp(): void
{
parent::setUp();
// 1.
$apiPath = '/foo';
$httpMethod = 'get';
$this->expectRequestResponseCompliant($apiPath, $httpMethod);
}
public function testFoo200OK(): void
{
// 2.
$response = $this->callFoo()->assertStatus(200);
}
/**
* @dataProvider dataProviderFooInvalidParameters
*/
public function testFoo422(array $invalidParameters): void
{
// 3.
$this->enableRequestCompliantAssertion(false);
$response = $this->callFoo($invalidParameters)->assertStatus(422);
}
/**
* @return TestResponse
*/
private function callFoo(?array $params = null): TestResponse
{
return $this->json(
'get',
'/foo',
$params ?? [
'default' => 'param',
]
);
}
- setUpで「このAPI実装がOpenAPIドキュメントに準拠していることを透過的にテストしたいよ」と登録しておきます。
- 当該APIをコールすると、検証が透過的に実施されます。
- バリデーションエラー(422レスポンス)のテストケースなど、検証を意図的にバイパスしたい場合もあるので、無効化できるようにしました。
テストをドライバとしてアプリケーションコードをデバッグする際にも有用です。
例えばレスポンス形式がOpenAPIドキュメントに準拠していない場合、次のようなエラーが出てテストが落ちます。
array:1 [
"hoge" => "fuga"
]
The validated document contains validation errors:
- The property _links is required
- The property _embedded is required
これで「仕様が正」という状態を担保できますね!テスト基底クラス側
アサート登録部分
namespace Tests;
...
use Illuminate\Foundation\Http\Events\RequestHandled;
...
abstract class TestCase extends BaseTestCase
{
...
/**
* @param string $apiPath
* @param string $httpMethod
* @param string $message
*/
public function expectRequestResponseCompliant(
string $apiPath,
string $httpMethod,
string $message = ''
): void {
Event::listen(
// 1.
RequestHandled::class,
function (RequestHandled $event) use ($apiPath, $httpMethod, $message) {
if ($this->requestCompliantAssertionEnabled) {
// 2.
$this->assertRequestCompliant(
$event->request,
$apiPath,
$httpMethod,
$message
);
}
if ($this->responseCompliantAssertionEnabled) {
// 3.
if ($event->response->getStatusCode() >= 500) {
return;
}
$this->assertResponseCompliant(
$event->response,
$apiPath,
$httpMethod,
$event->response->getStatusCode(),
$message
);
}
}
);
}
- LaravelのFeatureテストの
getJson()
等をコールするとRequestHandled
イベントが発火します。
laravel/framework 6.x 執筆時点HEADの該当ソースコード:
https://github.com/laravel/framework/blob/0dd1a50/src/Illuminate/Foundation/Http/Kernel.php#L122 - このイベントをリッスンし、HTTPリクエスト/レスポンスがOpenAPIドキュメントに準拠していることをアサートします。
- 5xxエラーまで捕まえるとデバッグしづらくなるので素通りさせています。
Swagger/OpenAPIドキュメントに準拠していることのアサートの実装部分
今回の仕組みを導入したプロジェクトではSwagger v2を採用していたので、検証ライブラリには WakeOnWeb/swagger を使用しました。
namespace Tests;
...
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use WakeOnWeb\Component\Swagger\Loader\YamlLoader;
use WakeOnWeb\Component\Swagger\Specification\Swagger;
use WakeOnWeb\Component\Swagger\SwaggerFactory;
use WakeOnWeb\Component\Swagger\Test\ContentValidator;
use WakeOnWeb\Component\Swagger\Test\Exception\SwaggerValidatorException;
use WakeOnWeb\Component\Swagger\Test\JustinRainbowJsonSchemaValidator;
use WakeOnWeb\Component\Swagger\Test\SwaggerValidator;
...
abstract class TestCase extends BaseTestCase
{
...
/**
* @param Request $request
* @param string $apiPath
* @param string $httpMethod
* @param string $message
*/
public function assertRequestCompliant(
Request $request,
string $apiPath,
string $httpMethod,
string $message = ''
): void {
// 1.
$swaggerValidator = $this->setupSwaggerValidator();
$psr7Factory = $this->createPsr7Factory();
// 2.
$psrRequest = $psr7Factory->createRequest($request);
// 3.
try {
$swaggerValidator->validateRequestFor(
$psrRequest,
strtoupper($httpMethod),
$apiPath
);
$this->assertTrue(true);
} catch (SwaggerValidatorException $e) {
$this->fail($message === '' ? $e->getMessage() : $message);
}
}
/**
* @param Response|JsonResponse $response
* @param string $apiPath
* @param string $httpMethod
* @param int $statusCode
* @param string $message
*/
public function assertResponseCompliant(
$response,
string $apiPath,
string $httpMethod,
int $statusCode,
string $message = ''
): void {
$swaggerValidator = $this->setupSwaggerValidator();
$psr7Factory = $this->createPsr7Factory();
$psrResponse = $psr7Factory->createResponse($response);
try {
$swaggerValidator->validateResponseFor(
$psrResponse,
strtoupper($httpMethod),
$apiPath,
$statusCode
);
$this->assertTrue(true);
} catch (SwaggerValidatorException $e) {
dump(json_decode($response->content()));
$this->fail($message === '' ? $e->getMessage() : $message);
}
}
/**
* @return SwaggerValidator
*/
private function setupSwaggerValidator(): SwaggerValidator
{
$swaggerValidator = new SwaggerValidator(
$this->loadSwagger()
);
$contentValidator = $this->createContentValidator();
$swaggerValidator->registerRequestValidator($contentValidator);
$swaggerValidator->registerResponseValidator($contentValidator);
return $swaggerValidator;
}
/**
* @return Swagger
*/
private function loadSwagger(): Swagger
{
$factory = new SwaggerFactory();
$factory->addLoader(new YamlLoader());
return $factory->buildFrom(__DIR__ . '/../swagger/default.yaml');
}
/**
* @return HttpMessageFactoryInterface
*/
private function createPsr7Factory(): HttpMessageFactoryInterface
{
$psr17Factory = new Psr17Factory();
return new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
}
/**
* @return ContentValidator
*/
private function createContentValidator(): ContentValidator
{
$contentValidator = new ContentValidator();
$contentValidator->registerContentValidator(new JustinRainbowJsonSchemaValidator());
return $contentValidator;
}
- Swagger/OpenAPI ドキュメントを読み込み、SwaggerValidatorを構築します。
キャッシュによる高速化の余地がありますがまた別の機会に… - 検証ライブラリがPSR-7形式のリクエスト/レスポンスオブジェクトを要求するので、Laravel (Symfony) のRequest/Responseを詰め替えます。
- 検証を実施します。
以下にcomposer.jsonのrequire-devより依存ライブラリを抜粋します。
- "nyholm/psr7": "^1.2"
- "symfony/psr-http-message-bridge": "^2.0"
- "symfony/yaml": "^5.0"
- "wakeonweb/swagger": "^1.0"
まとめ
- API実装がOpenAPIドキュメントに準拠していることを透過的に検証する仕組みを構築した