しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET Core Web API と NSwag を使って OpenAPI 定義を自動生成する

昔は ASP.NET で Swagger / OpenAPI 定義を出力するには Swashbuckle が定番でしたが、あまりアクティブではない時期が長かったせいか最近は NSwag を使うようにしています。

今では GitHub の Star 数も Swashbuckle より NSwag の方が多くなっています。

Docs でも Swashbuckle と共に NSwag を使ったドキュメントが公開されています。

機能的には Swashbuckle と NSwag で大きな差はないですが、NSwag は MSBuild 向けパッケージや CLI を使った静的な Swagger / OpenAPI 定義の生成が行えます。

Swagger / OpenAPI 正義は実行時にわざわざリフレクションを使用して生成する必要はゼロで、ビルド時や CI といったタイミングで生成できれば全く問題ないです。なので 2 つの方法で自動生成を行ってみます。

ローカルビルド時に OpenAPI 定義を生成する

手っ取り早いのが Visual Studio / .NET Core CLI でのビルド時に OpenAPI 定義を同時に生成してしまう方法です。NSwag の場合は NSwag.MSBuild をインストールすると比較的簡単に実現できます。

NSwag を使った静的な OpenAPI 定義の生成には nswag.json ファイルを用意した方が楽なので、今回の場合は以下のような定義を用意しておきました。

ASP.NET Core 向けのジェネレータを使って、ビルド済みアセンブリから定義を生成します。ファイルは csproj と同じディレクトリに置いておくと色々と楽です。

{
  "runtime": "NetCore31",
  "documentGenerator": {
    "aspNetCoreToOpenApi": {
      "output": "openapi.json",
      "outputType": "OpenApi3",
      "assemblyPaths": [
        "bin/$(Configuration)/netcoreapp3.1/WebApplication1.dll"
      ]
    }
  }
}

正直アセンブリパスの指定が面倒な感じですが、ビルド後に実行する処理なので仕方ない感じです。Visual Studio から生成物のフルパスを渡すことも出来ると思いますが、まずはシンプルな設定にしました。

ビルド後に NSwag を実行するために、csproj に Build 後のターゲットを追加します。Wiki に書いてある通りで大体は問題ないですが、今回は .NET Core 3.1 向けなので NSwagExe を 3.1 向けにします。

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="NSwag.AspNetCore" Version="13.5.0" />
    <PackageReference Include="NSwag.MSBuild" Version="13.5.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <Target Name="NSwag" AfterTargets="Build">
    <Exec Command="$(NSwagExe_Core31) run nswag.json /variables:Configuration=$(Configuration)" />
  </Target>

</Project>

これで Visual Studio や .NET Core CLI を使ったビルドを開始すると、アプリケーションのビルド後に NSwag が実行されて OpenAPI 定義が生成されるようになりました。

f:id:shiba-yan:20200519022606p:plain

出力された OpenAPI 定義をリポジトリに含めて、そのままコミットすることになると思いますが、この方法だとビルドを行わない限りは新しいバージョンが生成されないので、ビルドを忘れると古い OpenAPI 定義のままになってしまいます。

なので Pull Request 向けの CI では、最新の OpenAPI 定義が出力されているかの確認が必要でしょう。

CI では間違いなくアプリケーションのビルドを行うので、そのタイミングで最新の OpenAPI 定義が生成されます。そして HEAD との差分が発生していれば、生成忘れなのでエラーにすれば良いですね。Azure Pipelines を利用して以下のような Pipeline を作成しました。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- checkout: self
  persistCredentials: true
  clean: true

- script: dotnet build
  displayName: Build application

- script: |
    if [ -n "$(git status --porcelain)" ]; then
      git status
      exit 1
    fi
  displayName: Ensure up to date

git status --porcelain を使う方法は openapi-generator の CI を参考にしました。openapi-generator は最新のコードが生成されていないと CI で落ちるようになっているので、今回の目的と完全に一致しました。

例えばビルドせずに API 実装を変更してコミットした場合、以下のように CI で落ちるようになります。

f:id:shiba-yan:20200519022150p:plain

もちろんビルドしなおしてコミットすれば CI は成功します。

Pull Request で実行しておけば、master に古い定義が混ざることを防ぐことが出来ます。OpenAPI 定義をコードから生成している場合は、最低限このくらいのチェックは入れておきたいところです。

CI を使って OpenAPI 定義を生成する

Azure Pipelines などの CI を使って OpenAPI 定義のチェックを行うなら、むしろ CI で生成すれば良いという話にもなるので、Azure Pipelines で最新のコミットから OpenAPI 定義を生成するようにしてみます。

NSwag には CLI が提供されていますが、何故か .NET Core Global Tool としては提供されていないので、NPM で公開されているものを使うのが楽です。

今回も MSBuild を使った時と同様に nswag.json を作成しますが、アセンブリのパスではなくプロジェクトを指定する方法を選んでみました。アセンブリのパスは不要です。

{
  "runtime": "NetCore31",
  "documentGenerator": {
    "aspNetCoreToOpenApi": {
      "project": "WebApplication1.csproj",
      "output": "openapi.json",
      "outputType": "OpenApi3"
    }
  }
}

このファイルを使って npx nswag を呼び出せば OpenAPI 定義が生成されます。

最後に生成された OpenAPI 定義をリポジトリにコミットする必要があるので、Azure Pipelines で各種 Git コマンドが使えるように、ドキュメントを確認しつつセットアップします。

Build Service に対して git push のための権限を付ける必要がありますが、ドキュメントの内容が微妙に古くてはまりました。

以下のように Users に中にある Build Service に対して権限を付けると、コマンドが通るようになりました。

f:id:shiba-yan:20200519170950p:plain

今回作成した Pipeline は以下のようになります。どのタイミングで OpenAPI 定義を生成させるべきか悩みましたが、master にマージされた時で良いかなと思ったので、シンプルな形になっています。

Pull Request でも都度生成させることは出来ますが、feature branch に対してサーバー側でコミットを積んでいくと、ブランチが先に進んでしまうので手間かなと思いました。

trigger:
- master

pool:
  vmImage: 'ubuntu-latest'

steps:
- checkout: self
  persistCredentials: true
  clean: true

- script: dotnet build
  displayName: Build application

- script: npx nswag run WebApplication1/nswag.json /runtime:NetCore31
  displayName: Generate OpenAPI document

- script: |
    git config --global user.email "me@shibayan.jp"
    git config --global user.name "Tatsuro Shibamura"
    git checkout master
    git status
    git add .
    git commit -m "[skip ci] Update OpenAPI document"
    git push
  displayName: Commit new document

変更点がない場合は途中で処理を抜けても良いですが、オプションを付けない限り空のコミットは生成されないので、今のコマンドでも問題はないです。

Git の user.emailuser.name は自動生成したことが分かるように別名にした方が良いでしょうが、今回は Azure Pipelines での git push を行うテストも兼ねていたので適当なものを設定しています。

この状態で master に対して何かコミットすると、Azure Pipelines が最新の OpenAPI 定義を生成して、その定義をリポジトリに push してくれます。

f:id:shiba-yan:20200519011950p:plain

ちなみにコミットメッセージには [skip ci] など CI をスキップするための文字列を入れないと、コミットがトリガーになるので CI がループします。

作成されたコミットを確認すると、CI で自動生成された OpenAPI 定義が含まれています。

f:id:shiba-yan:20200519022041p:plain

これで OpenAPI 定義の生成を意識しなくても、常に CI によって最新の定義が生成されるようになりました。今回はリポジトリに追加しましたが、Azure Storage にコピーして公開するといった方法も選べます。

逆に公開されている OpenAPI 定義から Azure Pipelines などを使って自動的にクライアントを生成して、リポジトリにコミットするという処理も簡単に組めそうです。また別途試してみたいところです。