OpenAPIだってLintしたい

:::note info
この記事は、[ラクス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/rakus) の3日目の記事です。昨日は、 @chorei の [Kotlin + Spring Boot で OpenAPI Generator 体験記①（設定内容編）](https://qiita.com/chorei/items/884281ab874ad3098956) でした :christmas_tree: 
:::

OpenAPIは温かみのある手書き派の @oohira です。とはいっても、やはり手書きであるがゆえの些細なミスや、（OpenAPIのスキーマとしては問題ないけど）チームで定めた独自ルールの違反をいかに減らすかは悩みの種です。

そこで、この記事では [Spectral](https://github.com/stoplightio/spectral) を使って[^1]、OpenAPIもコードと同じようにLintする方法を紹介します。

https://github.com/stoplightio/spectral

### セットアップ

1. spectral コマンドのインストール
    ```shell
    $ npm install -g @stoplight/spectral-cli
    
    $ spectral --version
    6.11.0
    ```
1. 設定ファイルの作成
    ```yaml
    $ vi .spectral.yaml
    extends: ["spectral:oas"]
    ```
1. Lintの実行
    ```shell
    $ spectral lint petstore.yaml

    /work/petstore.yaml
      2:6   warning  info-contact           Info object must have "contact" object.                        info
      2:6   warning  info-description       Info "description" must be present and non-empty string.       info
      11:9  warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets.get
     15:11  warning  operation-tag-defined  Operation tags must be defined in global tags.                 paths./pets.get.tags[0]
     43:10  warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets.post
     47:11  warning  operation-tag-defined  Operation tags must be defined in global tags.                 paths./pets.post.tags[0]
      58:9  warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets/{petId}.get
     62:11  warning  operation-tag-defined  Operation tags must be defined in global tags.                 paths./pets/{petId}.get.tags[0]

    ✖ 8 problems (0 errors, 8 warnings, 0 infos, 0 hints)
    ```
    * 環境を汚したくない場合はDockerでも可
        ```shell
        $ docker run --rm -it -v $(pwd):/work -w /work stoplight/spectral lint petstore.yaml
        ```

### 既存ルールの調整

Spectralが標準で提供しているOpenAPI用のルールは、次のページから確認できます。

https://meta.stoplight.io/docs/spectral/4dec24461f3af-open-api-rules

ルールごとの Severity (`error, warn, info, hint, off`) を上書きすることで、検出レベルを調整することができます。

1. 設定ファイルの変更
    ```yaml
    $ vi .spectral.yaml
    extends: ["spectral:oas"]
    rules:
      info-contact: off
      info-description: info
      operation-tag-defined: error
    ```
1. Lintを実行
    ```shell
    $ spectral lint petstore.yaml

    /work/petstore.yaml
      2:6   information  info-description       Info "description" must be present and non-empty string.       info
      11:9      warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets.get
     15:11        error  operation-tag-defined  Operation tags must be defined in global tags.                 paths./pets.get.tags[0]
     43:10      warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets.post
     47:11        error  operation-tag-defined  Operation tags must be defined in global tags.                 paths./pets.post.tags[0]
      58:9      warning  operation-description  Operation "description" must be present and non-empty string.  paths./pets/{petId}.get
     62:11        error  operation-tag-defined  Operation tags must be defined in global tags.                 paths./pets/{petId}.get.tags[0]

    ✖ 7 problems (3 errors, 3 warnings, 1 info, 0 hints)
    ```

### カスタムルールの作り方

Spectralでは、チーム独自の規約などをカスタムルールとして定義してチェックさせることができます。OpenAPIの仕様違反であれば他にもツールはあると思いますが、チームのAPI規約をルール化できるという点がSpectralのうれしいところです。

詳細な仕様は、[ドキュメント](https://meta.stoplight.io/docs/spectral/d3482ff0ccae9-rules) を参照してもらうとして、簡単なルールの作り方だけ紹介します。ここでは、`operationId` がスネークケースであることをLintしたいとします。

* 対象となるOpenAPI
    ```yaml
    paths:
      /pets:
        get:
          summary: List all pets
          operationId: listPets    # キャメルケースなのでNG
    ```
* カスタムルール
    ```yaml
    rules:
      operation-id-snake-case:
        message: "operationIdはスネークケース. {{value}}"
        given: $.paths.*.*
        severity: error
        then:
          field: operationId
          function: casing
          functionOptions:
            type: snake
    ```
* 実行イメージ
    ```shell
    $ spectral lint petstore.yaml
     ...
     13:20        error  operation-id-snake-case  operationIdはスネークケース. listPets            paths./pets.get.operationId
    ```

ポイントは次の通りです。

1. `given` に書かれた [JSONPath](https://goessner.net/articles/JsonPath/index.html) で評価対象となるノードを絞り込む
1. 絞り込んだ各ノードの `field` がもつ値に対して、`function` が成立することを検証する
    * `function` には組み込みで提供される [コア関数](https://meta.stoplight.io/docs/spectral/cb95cf0d26b83-core-functions) のほかに、[JavaScriptで書いたカスタム関数](https://meta.stoplight.io/docs/spectral/a781e290eb9f9-custom-functions) を使うこともできます

:::note info
`given` と `field` で目的のフィールドを絞り込めるようにするまでが意外に大変な気がします。公式ドキュメントでも紹介されていますが、[JSONPath Online Evaluator](https://jsonpath.com/)などで確認しながら進めるとよいでしょう。
:::

### カスタムルールの例

いくつかのサンプルを紹介して、詳細な説明に代えたいと思います。

#### プロパティ名がスネークケース

`field` に `@key` を指定することで properties オブジェクトの各キーを対象にできます。また、properties 要素は paths 以下や components 以下などいくつかの場所で使われるため、 `$..` を使って階層を固定せずにマッチングさせます。

```yaml
Pet:
  type: object
  properties:
    id:              # ここに対するLint
      type: integer
    name:            # ここに対するLint
      type: string
```
```yaml
rules:
  properties-snake-case:
    message: "propertiesのキーはスネークケース"
    severity: error
    given: "$..properties"
    then:
      field: "@key"
      function: casing
      functionOptions:
        type: snake
```

#### URLが原則スネークケースで、パスパラメーターだけキャメルケース

`function` に `pattern` 関数を指定することで正規表現によるチェックを実現できます。なお、キャメルケースと言いつつ先頭文字と文字種ぐらいしかチェックしていない正規表現になっているので雑です。

```yaml
paths:
  /pets/{petId}:  # ここに対するLint
    get:
      summary: Info for a specific pet
```
```yaml
rules:
  paths-snake-case-except-params:
    message: "URLはスネークケース（パラメーターのみキャメルケース）"
    severity: error
    given: "$.paths"
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^([/a-z0-9_]|{[a-z][a-zA-Z0-9]*})*$"
```

#### array型のプロパティにはmaxItemsも定義

`given` がトリッキーですが、`*[?(フィルタ)]` でフィルタを満たす任意のノードになります。ここでは、`@.type=='array'` すなわち「typeプロパティの値がarrayであるようなノード」の意味になります。また、`function` に `truthy` 関数を指定することで、フィールドの存在チェックを実現できます。

```yaml
Pets:
  type: array
  maxItems: 100  # ここに対するLint
  items:
    $ref: "#/components/schemas/Pet"
```
```yaml
rules:
  array-max-items:
    message: "array型にはmaxItemsも必ず定義する"
    severity: error
    given: "$..*[?(@.type=='array')]"
    then:
      field: maxItems
      function: truthy
```

### CIへの組み込み

最後に、自動的にLintが実行されるようCIへ組み込んでおくとよいでしょう。[npmパッケージ](https://www.npmjs.com/package/@stoplight/spectral-cli) 以外にも、[Dockerイメージ](https://hub.docker.com/r/stoplight/spectral) や [GitHub Actions](https://github.com/stoplightio/spectral-action) も公開されているので、好みの方法で簡単に組み込めると思います。

### まとめ

この記事では、OpenAPIをLintするためのツールとしてSpectralを紹介しました。OpenAPIの手書き派が少ないのか、あまり情報が出回っていない気がしますが、プロジェクトの規約をうまくカスタムルール化できれば手軽にLintできて便利なんじゃないかなと思います。

### 参考情報

* [GitHub: stoplightio/spectral](https://github.com/stoplightio/spectral)
* [GitHub: OAI/OpenAPI-Specification](https://github.com/OAI/OpenAPI-Specification)
* [Spectralでパスが小文字英数字のみかをチェックするルール](https://qiita.com/suin/items/11532b5458558da612e5)

[^1]: [vacuum](https://github.com/daveshanley/vacuum) というSpectral互換かつ高速性をうたったLinterも試したのですが、まだ発展途上だったのでSpectralにしました。