ASP.NET Core 5.0 APIのSwaggerを設定する

# はじめに
.Net Framework 4.8がリリースされて、.Net Frameworkが遂に、アップデート停止になりました。
(バグ修正やセキュリティ修正は、継続されます。2020/12/17現在)
早急に対策が必要なわけではありませんが、.Net Coreへのマイグレーションを少しづつ進める必要性が高くなったというサインだと思います。
旧システムの.NET 5へのマイグレーションを進めるなかで、調査したSwagger設定を備忘録として、書き記します。

# Swaggerとは
Swaggerは、Wordnik Society社が、自社のAPIドキュメントの自動化のために作成したツールが源流になっているようです。
Apache 2.0オープンライセンスによって公開されていて、Apigee、Intuit、Microsoft、IBMなどに採用されたことで、広く知られるようになっていきます。
現在は、OpenAPI Initiativeという組織が、開発を牽引しているそうです。（グーグル、IBM、マイクロソフトなどが会員企業）
Swagger仕様は、OpenAPI仕様と名称が変更されていますが、Swaggerという名称が廃止された訳ではありません。
Swaggerは、OpenAPIの仕様に基づいた実装の名称ということで、残っています。(OpenAPIの実装は他にもある)
今まででも設定を行えば、利用することができましたが、.NET 5からは標準で組み込まれるようになりました。
デファクトスタンダードが見えてきたという判断があったのかも知れません。
こうなると避けて通るのは、デメリットが大きいので、マイグレーションの手を止めて、調査することにしました。

# 設定
テンプレートからプロジェクトを作成するとき「Enable OpenAPI support」というチェックボックスが表示されます。
オンにするとWeatherForecastというサンプルAPIが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7839/880436c4-a28e-48e8-b0d6-ad1fb4420c60.png)
何を設定しなくてもデフォルトで、Swaggerが使える状態になっています。
ただし、Swaggerは設定範囲が広く思い通りに動かしたい場合には、細かく設定する必要があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7839/0c2a90be-5739-c276-3133-fad25531bf11.png)

## ブラウザ起動URLの変更
デフォルト状態では、エンドポイントが、`https://localhost:44363/swagger/index.html`になります。
ページがあるプロジェクトでは、別URLになっているのは便利だと思うのですが、APIプロジェクトでは、`https://localhost:44363/index.html`の方が自然な気がするので、修正してみました。

```csharp:Startup.cs
app.UseSwaggerUI(c => {
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebApplication1 v1");
    c.RoutePrefix = string.Empty; // 追加
});
```
ブラウザの起動URLも修正します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7839/37f2028f-ca6f-fb76-e1c0-37a3bb944a30.png)

## パッケージ導入
デフォルト状態では、Swashbuckle.AspNetCoreというパッケージだけが設定されています。
このパッケージは、Swagger UIへ渡すためのSwagger Specファイル(XML)をプロジェクト内のクラスファイルを巡回して、生成する機能を持っているみたいです。
だたし、これだけだと機能が足りないので、下記のパッケージを導入します。
`Microsoft.AspNetCore.Mvc.Versioning`
`Swashbuckle.AspNetCore.Annotations`
`Swashbuckle.AspNetCore.Filters`

## コメントの追従
XML コメントを有効にすると、文書化されない型とメンバーは警告メッセージで示されます。
警告コード1591の抑制も同時に記載します。

```xml:xxx.csproj
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>
```

コメントが表示されるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7839/23a66e6e-5aa7-7339-2e13-1a19d755408e.png)

## バージョン
WebAPIのベストプラクティスとして、URIにバージョンを付加して管理するというものがあります。
`/api/v1/login`のように、しておけばloginに後方互換性のない修正を加えることが簡単になります。
Swagger UIにもバージョンの切り替えや表示があるのですが、デフォルトでは、コードに追従してくれません。

```csharp:Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddApiVersioning();

    services.AddSwaggerGen(option =>
    {
        option.SwaggerDoc("v1", new OpenApiInfo
        {
            Version = "v1",
            Title = "ASP.NET Core Web API",
            Description = "スマートフォンアプリ向け Web API",
        });
        option.SwaggerDoc("v2", new OpenApiInfo
        {
            Version = "v2",
            Title = "ASP.NET Core Web API",
            Description = "スマートフォンアプリ向け Web API",
        });
        option.OperationFilter<RemoveVersionParameterFilter>();
        option.DocumentFilter<ReplaceVersionWithExactValueInPathFilter>();
        option.EnableAnnotations();

        option.DocInclusionPredicate((name, apiDescription) =>
        {
            if (!apiDescription.TryGetMethodInfo(out MethodInfo methodInfo)) return false;
                var versions = methodInfo.DeclaringType
                                         .GetCustomAttributes(true)
                                         .OfType<ApiVersionAttribute>()
                                         .SelectMany(attr => attr.Versions);

                return versions.Any(v => $"v{v.ToString()}" == name);
            });

            // Set the comments path for the Swagger JSON and UI.
            var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
            var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
            option.IncludeXmlComments(xmlPath);
        });
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   if (env.IsDevelopment())
   {
       // ローカル環境でのみSwaggerを表示する
       app.UseDeveloperExceptionPage();
       app.UseSwagger();
       app.UseSwaggerUI(c => {
           c.SwaggerEndpoint($"/swagger/v2/swagger.json", $"WebApi v2");
           c.SwaggerEndpoint($"/swagger/v1/swagger.json", $"WebApi v1");
           c.RoutePrefix = string.Empty;
       });
   }
}
```

```csharp:RemoveVersionParameterFilter.cs
    using System.Linq;
    using Microsoft.OpenApi.Models;
    using Swashbuckle.AspNetCore.SwaggerGen;

    public class RemoveVersionParameterFilter : IOperationFilter
    {
        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            var versionParameter = operation.Parameters.SingleOrDefault(p => p.Name == "version");

            if (versionParameter != null)
            {
                operation.Parameters.Remove(versionParameter);
            }
        }
    }
```

```csharp:ReplaceVersionWithExactValueInPathFilter.cs
    using Microsoft.OpenApi.Models;
    using Swashbuckle.AspNetCore.SwaggerGen;

    public class ReplaceVersionWithExactValueInPathFilter : IDocumentFilter
    {
        public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
        {
            var paths = new OpenApiPaths();
            foreach (var path in swaggerDoc.Paths)
            {
                paths.Add(path.Key.Replace("v{version}", swaggerDoc.Info.Version), path.Value);
            }
            swaggerDoc.Paths = paths;
        }
    }
```
バージョン表示されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7839/464155be-00dc-be2a-ad10-6e09a8987d17.png)

```csharp:WeatherForecastController.cs
    [ApiVersion("2")]
    [ApiController]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        /// <summary>
        /// メソッドコメントの表示
        /// </summary>
        [HttpGet]
        [MapToApiVersion("2")]
        public IEnumerable<WeatherForecast> Get()
        {
        }
    }
```

## リクエスト、レスポンス例の表示
デフォルトの状態だとスキーマしか表示されません。
実際のテストデータが入った例を表示するには、属性を利用します。
まず、Startup.csへ設定を追加します。

```csharp:Startup.cs
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSwaggerGen(option =>
        {
            option.SwaggerDoc("v2", new OpenApiInfo
            {
                Version = "v2",
                Title = "ASP.NET Core Web API",
                Description = "スマートフォンアプリ向け Web API",
            });
            option.ExampleFilters();
            option.OperationFilter<AddResponseHeadersFilter>(); // [SwaggerResponseHeader]

            option.OperationFilter<AppendAuthorizeToSummaryOperationFilter>(); // Adds "(Auth)" to the summary so that you can see which endpoints have Authorization
                                                                               // or use the generic method, e.g. c.OperationFilter<AppendAuthorizeToSummaryOperationFilter<MyCustomAttribute>>();

            // add Security information to each operation for OAuth2
            option.OperationFilter<SecurityRequirementsOperationFilter>();
            // or use the generic method, e.g. c.OperationFilter<SecurityRequirementsOperationFilter<MyCustomAttribute>>();

            // if you're using the SecurityRequirementsOperationFilter, you also need to tell Swashbuckle you're using OAuth2
            option.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
            {
                Description = "Standard Authorization header using the Bearer scheme. Example: \"bearer {token}\"",
                In = ParameterLocation.Header,
                Name = "Authorization",
                Type = SecuritySchemeType.ApiKey
            });

            option.DocumentFilter<ReplaceVersionWithExactValueInPathFilter>();
            option.EnableAnnotations();
        }
    }
```
### サンプルリクエストの作成
サンプルリクエスト用のPostLoginRequestクラスを作成します。

```csharp:PostLoginRequest.cs
    using System.Runtime.Serialization;
    using Swashbuckle.AspNetCore.Filters;

    [DataContract]
    public class PostLoginRequest
    {
        [DataMember(Name = "login_id")]
        [JsonPropertyName("login_id")]
        public string LoginId { get; set; }
        [DataMember(Name = "user_pw")]
        [JsonPropertyName("user_pw")]
        public string UserPw { get; set; }
    }

    public class PostLoginRequestExample : IExamplesProvider<PostLoginRequest>
    {
        public PostLoginRequest GetExamples()
        {
            return new PostLoginRequest
            {
                LoginId = "userid",
                UserPw = "password",
            };
        }
    }
```
使うときには、SwaggerRequestExampleを使用します。

```csharp:LoginController.cs
    [ApiVersion("1")]
    [ApiController]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class LoginController : ControllerBase
    {
        [HttpPost]
        [MapToApiVersion("1")]
        [Consumes("application/json")]
        [Produces("application/json")]
        [SwaggerRequestExample(typeof(WebApi.Models.v1.PostLoginRequest), typeof(WebApi.Models.v1.PostLoginRequestExample))]
        public WebApi.Models.v2.LoginResponse Post([FromBody] WebApi.Models.v1.PostLoginRequest value)
        {
        }
    }
```
マイグレーション元のプロジェクトでは、プロパティをスネークケースで設計していたので、JsonPropertyName属性で補正しています。

### サンプルレスポンスの作成
サンプルレスポンスに関してもSwaggerResponseExample属性を使えば、同じように実装できます。
SwaggerResponseExampleは、ステータスコード別に設定できます。
問題なのは、HttpStatusCodeを返すパターンです。
そのままだとStatusCodes.Status200OKのスキーマが表示されてしまいます。
この方法が、正解かは分からないのですが、ステータスコードだけを返すExampleクラスを作成しました。

```csharp:StatusCodesExample.cs
    public class StatusCodes200OKExample : IExamplesProvider<HttpStatusCode>
    {
        public HttpStatusCode GetExamples()
        {
            return HttpStatusCode.OK;
        }
    }
    public class StatusCodes400BadRequestExample : IExamplesProvider<HttpStatusCode>
    {
        public HttpStatusCode GetExamples()
        {
            return HttpStatusCode.BadRequest;
        }
    }
    public class StatusCodes401UnauthorizedExample : IExamplesProvider<HttpStatusCode>
    {
        public HttpStatusCode GetExamples()
        {
            return HttpStatusCode.Unauthorized;
        }
    }
    public class StatusCodes404NotFoundExample : IExamplesProvider<HttpStatusCode>
    {
        public HttpStatusCode GetExamples()
        {
            return HttpStatusCode.NotFound;
        }
    }
    public class StatusCodes500InternalServerErrorExample : IExamplesProvider<HttpStatusCode>
    {
        public HttpStatusCode GetExamples()
        {
            return HttpStatusCode.InternalServerError;
        }
    }
```
使い方は、こんな感じ。

```csharp:LoginController.cs
        [HttpPost]
        [MapToApiVersion("1")]
        [Consumes("application/json")]
        [Produces("application/json")]
        [SwaggerRequestExample(typeof(WebApi.Models.v1.PostLoginRequest), typeof(WebApi.Models.v1.PostLoginRequestExample))]
        [SwaggerResponse(StatusCodes.Status401Unauthorized, "認証失敗", typeof(HttpStatusCode))]
        [SwaggerResponseExample(StatusCodes.Status401Unauthorized, typeof(StatusCodes401UnauthorizedExample))]
        [SwaggerResponse(StatusCodes.Status500InternalServerError, "サーバーエラー", typeof(HttpStatusCode))]
        [SwaggerResponseExample(StatusCodes.Status500InternalServerError, typeof(StatusCodes500InternalServerErrorExample))]
        public WebApi.Models.v2.LoginResponse Post([FromBody] WebApi.Models.v1.PostLoginRequest value)
        {
        }
```
表示は、こんな感じになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7839/83056dbe-c9b5-4560-764a-55758a2a387d.png)

# 今後
今回は、マイグレーションのために、Swaggerを利用しましたが、新規プロジェクトの場合には、Swagger Editerで、APIのスペックを作成してからSwagger Codegenで、APIのスタブを生成するという作業手順になります。
その手順については、またの機会に調べたいと思います。