開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. PHP >
  4. Laravel >
  5. LaravelアプリケーションのAPIがSwagger/OpenAPIドキュメントに準拠していることを透過的にテストする

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)

以下、OASにしたがって記述されたAPI仕様文書を「OpenAPIドキュメント」と呼びます。
 

仕様がさき、実装があと

巷では、APIバックエンドのソースコードからOpenAPIドキュメントを生成するやり方がよく見られます。 この方法ではバックエンドの実装が正となり、仕様が追従します。 概念クラス図で表すと次のようになります。

oas_from_impl.png

いっぽう、OpenAPIドキュメントを先に作り、実装が従うという形をとることもできます。 概念クラス図で表すと次のようになります。


impl_for_oas_with_mock.png

本記事では後者の方法に焦点を当てます。

OpenAPIドキュメントでAPI実装を検証する

上記の概念クラス図では、OpenAPIドキュメントをinterface、APIバックエンドをclassで表しました。
オブジェクト指向言語には、「classがinterfaceを正しく実装しなければコンパイルを通さない」といった仕組みが備わっています。
同様に、「APIバックエンドがOpenAPIドキュメントを正しく実装しなければテストを落とす」といった仕組みを作ることができます。
OpenAPIドキュメントによるAPI実装の検証を行うためのPHPライブラリには次のようなものがあります。

透過的に検証したい

商用環境でエラーを出したいわけではないので、自動テスト環境での検証に絞って話を進めます。
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',
            ]
        );
    }

  1. setUpで「このAPI実装がOpenAPIドキュメントに準拠していることを透過的にテストしたいよ」と登録しておきます。
  2. 当該APIをコールすると、検証が透過的に実施されます。
  3. バリデーションエラー(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
                    );
                }
            }
        );
    }
  1. LaravelのFeatureテストのgetJson()等をコールすると RequestHandledイベントが発火します。
    laravel/framework 6.x 執筆時点HEADの該当ソースコード:
    https://github.com/laravel/framework/blob/0dd1a50/src/Illuminate/Foundation/Http/Kernel.php#L122
  2. このイベントをリッスンし、HTTPリクエスト/レスポンスがOpenAPIドキュメントに準拠していることをアサートします。
  3. 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;
    }
  1. Swagger/OpenAPI ドキュメントを読み込み、SwaggerValidatorを構築します。
    キャッシュによる高速化の余地がありますがまた別の機会に…
  2. 検証ライブラリがPSR-7形式のリクエスト/レスポンスオブジェクトを要求するので、Laravel (Symfony) のRequest/Responseを詰め替えます。
  3. 検証を実施します。

以下に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ドキュメントに準拠していることを透過的に検証する仕組みを構築した
TOPに戻る