はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#】ASP.NET CoreのMinimalAPIでOpenAPIドキュメントを自動生成してSwaggerUIやReDocで可視化する(NSwag.AspNetCore)

はじめに

今回はASP.NET CoreMinimal APIで自動でOpenAPIドキュメントを生成してSwaggerUIReDocで可視化する方法を紹介したいと思います。

learn.microsoft.com

ちなみにMicrosoft公式が出しているMicrosoft.AspNetCore.OpenApiというOpenAPIドキュメントを生成するパッケージがあるのですが、こちらはSwaggerUIやReDocといったOpenAPIドキュメントの可視化するための機能がありませんので注意してください。
www.nuget.org

既定では、Microsoft.AspNetCore.OpenApi パッケージには、OpenAPI ドキュメントを視覚化または操作するための組み込みサポートは付属していません。

生成された OpenAPI ドキュメントを使用する | Microsoft Learn

概要

NSwag.AspNetCoreOpenAPIドキュメントの生成やC#クライアントコード生成、SwaggerUIやReDocによる可視化をサポートしているライブラリです。

NSwag is a Swagger/OpenAPI 2.0 and 3.0 toolchain for .NET, .NET Core, Web API, ASP.NET Core, TypeScript (jQuery, AngularJS, Angular 2+, Aurelia, KnockoutJS and more) and other platforms, written in C#. The OpenAPI/Swagger specification uses JSON and JSON Schema to describe a RESTful web API. The NSwag project provides tools to generate OpenAPI specifications from existing ASP.NET Web API controllers and client code from these OpenAPI specifications.

// DeepL翻訳
NSwagは、.NET、.NET Core、Web API、ASP.NET Core、TypeScript(jQuery、AngularJS、Angular 2+、Aurelia、KnockoutJSなど)、その他のプラットフォーム用のSwagger/OpenAPI 2.0および3.0ツールチェーンで、C#で書かれています。OpenAPI/Swagger仕様は、JSONとJSON Schemaを使用してRESTfulなWeb APIを記述します。NSwagプロジェクトは、既存のASP.NET Web APIコントローラからOpenAPI仕様を生成するツールや、これらのOpenAPI仕様からクライアントコードを生成するツールを提供しています。

ちなみに公式ドキュメントではNSwagと一緒にSwashbuckleも紹介されていますが、こちらは.NET9以降では使用できないとのこと(検証してないので本当か怪しいが...)なのでご注意ください。

Swashbuckle は .NET 9 以降では使用できません。 別の方法については、「ASP.NET Core API アプリでの OpenAPI サポートの概要」をご覧ください。

Swashbuckle と ASP.NET Core の概要 | Microsoft Learn

インストール方法

NuGetからインストールします。

$ dotnet add package NSwag.AspNetCore --version 14.2.0

https://www.nuget.org/packages/NSwag.AspNetCore/#versions-body-tab

また名前の通り、ASP.NET Core用のものなので注意してください。

# ASP.NET CoreのMinimal APIでのプロジェクト作成
$ dotnet new web -f net9.0

OpenAPIに基づいたドキュメントを生成する

var builder = WebApplication.CreateBuilder(args);

// 自動でAPIを解析する (MinimalAPIのみ必要になります)
// ref : https://stackoverflow.com/questions/71932980/what-is-addendpointsapiexplorer-in-asp-net-core-6/71933535#71933535
builder.Services.AddEndpointsApiExplorer();

// OpenAPI Generatorをサービスとして追加
builder.Services.AddOpenApiDocument();

var app = builder.Build();

app.MapGet("/", Results<BadRequest, Ok<string>> (string name) =>
{
    if (string.IsNullOrEmpty(name))
    {
        return TypedResults.BadRequest();
    }
    return TypedResults.Ok("{ \"message\": \"Hello, " + name + "\" }");
});

if(app.Environment.IsDevelopment())
{
    // OpenAPI に基づいたドキュメントを生成する ミドルウェア追加
    // Available at: http://localhost:<port>/swagger/v1/swagger.json
    app.UseOpenApi();
}

app.Run();

↓ 吐き出される例

{
  "x-generator": "NSwag v14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))",
  "openapi": "3.0.0",
  "info": {
    "title": "My Title",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "http://localhost:5006"
    }
  ],
  "paths": {
    "/": {
      "get": {
        "operationId": "Get",
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "x-position": 1
          }
        ],
        "responses": {
          "400": {
            "description": ""
          },
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {}
}



一点気をつけてほしいことは、Minimal APIのときのみAddEndpointsApiExplorerを呼び出す必要があるということです。詳細については添付したStackOverflowを参照してください。

// APIを解析する (MinimalAPIのみ必要になります)
builder.Services.AddEndpointsApiExplorer();

c# - What is AddEndpointsApiExplorer in ASP.NET Core 6 - Stack Overflow

WebAPIのドキュメント情報を追記する

吐き出されたswagger.jsonにはtitleversionsなどの情報が含まれていたと思うのですが、これらをカスタマイズすることができます。

builder.Services.AddOpenApiDocument(options =>
{
    options.PostProcess = document =>
    {
        document.Info = new OpenApiInfo
        {
            Version = "v1",
            Title = "MinimalAPI Sample",
            Description = "Sample API for MinimalAPI",
            TermsOfService = "https://example.com/terms",
            Contact = new OpenApiContact
            {
                Name = "Example Contact",
                Url = "https://example.com/contact"
            },
            License = new OpenApiLicense
            {
                Name = "Example License",
                Url = "https://example.com/license"
            }
        };
    };
});
情報を付加した様子

↓OpenAPIドキュメントの一部

{
  "x-generator": "NSwag v14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))",
  "openapi": "3.0.0",
  "info": {
    "title": "MinimalAPI Sample",
    "description": "Sample API for MinimalAPI",
    "termsOfService": "https://example.com/terms",
    "contact": {
      "name": "Example Contact",
      "url": "https://example.com/contact"
    },
    "license": {
      "name": "Example License",
      "url": "https://example.com/license"
    },
    "version": "v1"
  },
...

Swagger v2.0の仕様に従ったドキュメントを生成する

// OpenAPI v3.0
// builder.Services.AddOpenApiDocument();

// Swagger v2.0
builder.Services.AddSwaggerDocument();

ドキュメント名を変更する

// デフォルトのドキュメント名は "v1"
services.AddOpenApiDocument(document => document.DocumentName = "a");
services.AddSwaggerDocument(document => document.DocumentName = "b");

またドキュメント名を変換するとURLも変わりますので注意してください。

http://localhost:<port>/swagger/<document-name>/swagger.json

Swagger UIを利用する

if(app.Environment.IsDevelopment())
{
    // OpenAPI に基づいたドキュメントを生成する ミドルウェア追加
    // Available at: http://localhost:<port>/swagger/v1/swagger.json
    app.UseOpenApi();
    
    // SwaggerUI を立ち上げることができるようにする ミドルウェア追加
    // Available at: http://localhost:<port>/swagger
    app.UseSwaggerUi();
}
利用した様子

デフォルトで読み込むOpenAPIドキュメントは/swagger/{document-name}/swagger.jsonになります。

Redocを利用する

if(app.Environment.IsDevelopment())
{
    // OpenAPI に基づいたドキュメントを生成する
    // Available at: http://localhost:<port>/swagger/v1/swagger.json
    app.UseOpenApi();
    
    // ReDoc をを立ち上げることができるようにする ミドルウェア追加
    app.UseReDoc(options =>
    {
        options.Path = "/redoc";
    });
}
利用した様子

コード生成する

NSwag.CodeGeneration.CSharpを用いることでOpenAPIドキュメントからC#クライアントコードを生成することができます。

// Program.cs
var document = await OpenApiDocument.FromFileAsync("openapi.json");
var clientSettings = new CSharpClientGeneratorSettings 
{
    ClassName = "MyClass",
    CSharpGeneratorSettings = 
    {
        Namespace = "MyNamespace"
    }
};

var clientGenerator = new CSharpClientGenerator(document, clientSettings);
var code = clientGenerator.GenerateFile();

↓生成すると...

//----------------------
// <auto-generated>
//     Generated using the NSwag toolchain v14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org)
// </auto-generated>
//----------------------

// ...

namespace MyNamespace
{
    using System = global::System;

    [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")]
    public partial class MyClass 
    {
        // ...

        /// <exception cref="ApiException">A server side error occurred.</exception>
        public virtual System.Threading.Tasks.Task<string> GetAsync(string name)
        {
            return GetAsync(name, System.Threading.CancellationToken.None);
        }

        /// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
        /// <exception cref="ApiException">A server side error occurred.</exception>
        public virtual async System.Threading.Tasks.Task<string> GetAsync(string name, System.Threading.CancellationToken cancellationToken)
        {
            if (name == null)
                throw new System.ArgumentNullException("name");

            var client_ = _httpClient;
            var disposeClient_ = false;
            try
            {
                using (var request_ = new System.Net.Http.HttpRequestMessage())
                {
                    request_.Method = new System.Net.Http.HttpMethod("GET");
                    request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));

                    var urlBuilder_ = new System.Text.StringBuilder();
                    if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
                    // Operation Path: ""
                    urlBuilder_.Append('?');
                    urlBuilder_.Append(System.Uri.EscapeDataString("name")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(name, System.Globalization.CultureInfo.InvariantCulture))).Append('&');
                    urlBuilder_.Length--;

                    PrepareRequest(client_, request_, urlBuilder_);

                    var url_ = urlBuilder_.ToString();
                    request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);

                    PrepareRequest(client_, request_, url_);

                    var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
                    var disposeResponse_ = true;
                    try
                    {
                        var headers_ = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.IEnumerable<string>>();
                        foreach (var item_ in response_.Headers)
                            headers_[item_.Key] = item_.Value;
                        if (response_.Content != null && response_.Content.Headers != null)
                        {
                            foreach (var item_ in response_.Content.Headers)
                                headers_[item_.Key] = item_.Value;
                        }

                        ProcessResponse(client_, response_);

                        var status_ = (int)response_.StatusCode;
                        if (status_ == 400)
                        {
                            string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
                            throw new ApiException("A server side error occurred.", status_, responseText_, headers_, null);
                        }
                        else
                        if (status_ == 200)
                        {
                            var objectResponse_ = await ReadObjectResponseAsync<string>(response_, headers_, cancellationToken).ConfigureAwait(false);
                            if (objectResponse_.Object == null)
                            {
                                throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
                            }
                            return objectResponse_.Object;
                        }
                        else
                        {
                            var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
                            throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
                        }
                    }
                    finally
                    {
                        if (disposeResponse_)
                            response_.Dispose();
                    }
                }
            }
            finally
            {
                if (disposeClient_)
                    client_.Dispose();
            }
        }
        // ...

この生成されたコードを利用すれば、簡単にサーバーにリクエストが送れます。

MyNamespace.MyClass myClass = new MyNamespace.MyClass(new HttpClient());
var response = await myClass.GetAsync("Name");

// { "message": "Hello, Name" }
Console.WriteLine(response);