Laravelで実装したAPIがOpenAPIで記述された仕様に準拠しているかテストする

## はじめに
APIを開発する上で、多くの場合、仕様書も作成するかと思いますが、どのように作成しているでしょうか？
この記事では、[OpenAPI](https://www.openapis.org/)形式で記述されたAPI仕様があって、その仕様にAPIの実装が準拠しているかテストする方法を紹介します。

## tl;dr
- [thephpleague/openapi-psr7-validator](https://github.com/thephpleague/openapi-psr7-validator)を使って、`Request`および`Response`がAPI仕様に準拠しているか検証する
- [実行可能なサンプル](https://github.com/imunew/laravel-petstore-api)を用意したので、`git clone`して色々試してほしい

## `OpenAPI v3`とは（`Swagger v2`との違い）
詳しくは後述の参考リンクを参照してほしいのですが、誤解を恐れずに要点をまとめると以下のようになります。

- APIを定義する仕様として、2010年に`Swagger 1.0`がリリースされる
- `Swagger`は、`Open API Initiative`に寄贈され、2017-07-26に`Open API 3.0`がリリースされる
- `Open API 3.0`は`Swagger 2.0`から大幅な変更が加えられており、互換性はない

### 参考リンク
- https://en.wikipedia.org/wiki/OpenAPI_Specification
- https://swagger.io/docs/specification/about/
- https://news.mynavi.jp/itsearch/article/devsoft/3854

## `OpenAPI v3`に対応した検証ライブラリは多くない（2019年10月1日現在）
`Swagger v2`に対応したPHP製の検証ライブラリはいくつかあります。
しかし、いずれも、`OpenAPI v3`には未対応です。

### `WakeOnWeb/swagger`
[OpenAPI v3 support · Issue #16 · WakeOnWeb/swagger](https://github.com/WakeOnWeb/swagger/issues/16)はいまだに`Open`のままです。

### `nabbar/SwaggerValidator-PHP`
[README.md](https://github.com/nabbar/SwaggerValidator-PHP/blob/master/README.md)を見ると、`swagger / openapi version 3.0 (release >= 2.0)`とあるので、v2.0以上だったら`Open API 3.0`に対応しているのかと思いきや、最新のバージョンは`1.3.2`です。
対応予定だけ書いて力尽きたようです。

## `OpenAPI v3`形式のファイルを`JSON Schema`に変換すれば、検証はできる
[laravel-petstore-api](https://github.com/imunew/laravel-petstore-api)の最初のコミットでもこの方法を使っているのですが、`OpenAPI v3`形式のファイルを`JSON Schema`に変換し、`Request`および`Response`の検証はできます。

ポイントは以下のとおりです。

- [openapi2schema](https://www.npmjs.com/package/openapi2schema)を使って、`OpenAPI v3`から`JSON Schema`へ変換する
- [justinrainbow/json-schema](https://github.com/justinrainbow/json-schema)を使って、`Request`および`Response`を検証する

## 2019年9月 `thephpleague/openapi-psr7-validator`がリリースされる
この記事を書いている途中で気付いてしまったのですが、`thephpleague/openapi-psr7-validator`という何とも期待の持てるライブラリがあったので、試してみました。
結果、期待どおり動いたので、それまでの`JSON Schema`に変換してから検証する実装、関連ライブラリを全部捨てて`thephpleague/openapi-psr7-validator`で実装し直しました。

## 実装
実装は下記のようになりました。

```php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\TestResponse;
use Nyholm\Psr7\Factory\Psr17Factory;
use OpenAPIValidation\PSR7\Exception\ValidationFailed;
use OpenAPIValidation\PSR7\OperationAddress;
use OpenAPIValidation\PSR7\ValidatorBuilder;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Tests\TestCase;

/**
 * Trait AssertResponseCompliantForSwaggerApiSpec
 * @package Tests\Feature
 *
 * @mixin TestCase
 */
trait OpenApiSpecAssertions
{
    /**
     * @param TestResponse $testResponse
     * @param string $method
     * @param string $path
     */
    protected function assertResponseCompliantForOpenApiSpec(
        TestResponse $testResponse,
        string $method,
        string $path
    ) {
        $validator = (new ValidatorBuilder())
            ->fromYamlFile(__DIR__. '/../ApiSpec/petstore-expanded.yaml')
            ->getResponseValidator()
        ;

        $operation = new OperationAddress($path, strtolower($method)) ;

        $psr17Factory = new Psr17Factory();
        $psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
        $psrResponse = $psrHttpFactory->createResponse($testResponse->baseResponse);

        try {
            $validator->validate($operation, $psrResponse);
        } catch (ValidationFailed $validationFailed) {
            self::fail($validationFailed->getPrevious()->getMessage());
        }
    }

    /**
     * @param array $requestBody
     * @param string $method
     * @param string $uri
     */
    protected function assertRequestCompliantForOpenApiSpec(
        array $requestBody,
        string $method,
        string $uri
    ) {
        $validator = (new ValidatorBuilder())
            ->fromYamlFile(__DIR__. '/../ApiSpec/petstore-expanded.yaml')
            ->getRequestValidator()
        ;

        $psr17Factory = new Psr17Factory();
        $json = json_encode($requestBody, JSON_UNESCAPED_UNICODE);
        $stream = $psr17Factory->createStream($json);
        $stream->rewind();
        $request = $psr17Factory->createRequest(strtolower($method), $uri)
            ->withBody($stream)
            ->withHeader('Content-Type', 'application/json')
        ;

        try {
            $validator->validate($request);
        } catch (ValidationFailed $validationFailed) {
            self::fail($validationFailed->getPrevious()->getMessage());
        }
    }
}
```

### ポイント
- `tests/ApiSpec/petstore-expanded.yaml`に`OpenAPI v3`形式のファイルを配置
- `OpenAPIValidation\PSR7\ValidatorBuilder`の`fromYamlFile`で`validator`を生成
- [PSR-7](https://www.php-fig.org/psr/psr-7/)に準拠した`Request`/`Response`でなければいけないので、変換する必要がある
- `PSR-7`形式への変換については、[The PSR-7 Bridge (Symfony Docs)](https://symfony.com/doc/current/components/psr7.html)を参照
- 仕様に違反した場合は、`OpenAPIValidation\PSR7\Exception\ValidationFailed`が`throw`される
- 今回はテストを失敗させるようにした

## API仕様に違反すると
API仕様を変更して、テストを失敗させてみます。

任意項目だった`NewPet`および`Pet`の`tag`という`property`を必須（`required`）にして、テストを実行します。

```yml
openapi: "3.0.0"
## 中略 ##

components:
  schemas:
    Pet:
      allOf:
        - $ref: '#/components/schemas/NewPet'
        - type: object
          required:
          - id
          properties:
            id:
              type: integer
              format: int64

    NewPet:
      type: object
      required:
        - name
        - tag ## この行を追加
      properties:
        name:
          type: string
        tag:
          type: string

```

> Keyword validation failed: Required property 'tag' must be present in the object

というメッセージとともに、テストが失敗します。

```bash
$ vendor/bin/phpunit
PHPUnit 8.3.5 by Sebastian Bergmann and contributors.

..FFF...                                                            8 / 8 (100%)

Time: 405 ms, Memory: 24.00 MB

There were 3 failures:

1) Tests\Feature\Pet\IndexTest::indexSuccess
Keyword validation failed: Required property 'tag' must be present in the object

/path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:45
/path/to/laravel-petstore-api/tests/Feature/Pet/IndexTest.php:23

2) Tests\Feature\Pet\ShowTest::showSuccess
Keyword validation failed: Required property 'tag' must be present in the object

/path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:45
/path/to/laravel-petstore-api/tests/Feature/Pet/ShowTest.php:24

3) Tests\Feature\Pet\StoreTest::storeSuccess
Keyword validation failed: Required property 'tag' must be present in the object

/path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:76
/path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:98
/path/to/laravel-petstore-api/tests/Feature/Pet/StoreTest.php:23

FAILURES!
Tests: 8, Assertions: 13, Failures: 3.
```

## おわりに
API仕様に準拠しているか、実装が変わるたびに検証されていないと、しだいにAPI仕様が陳腐化し、結果、「今動いている実装が正しい」ということになりがちです。
もちろん、動いているAPIから仕様書を自動生成するというアプローチもありますが、以下のような問題があると思います。

- API仕様を変更するのに`PHP`を触らなければいけない
- API仕様を`PHP`のアノテーションで定義する形式だと、検査されずに陳腐化する可能性がある

なので、先にAPI仕様を`OpenAPI v3`形式で作成してから、クライアントチームと合意をとりながら開発を進めていくのが良いのかなと個人的には考えています。

この記事が、日々、多くのAPIを実装、保守しているサーバーサイドエンジニアの助けになれば幸いです。
ではでは。