通過 Refitter CLI,從 Swagger / OpenAPI Specification 檔案,產生 Refit Interfaces Client

一直以來都是用 NSwag 來產生 OpenAPI Client & Server Code,但它所產生出來的 Client Code 會 throw Exception,這讓我在商業流程的控制需要額外付出一些心力,為了解決這問題,我會額外再墊一層,最近逛到有人分享 Refit 這個套件,它所產生出來的具名 Method 不會拋出例外,讓我可以根據 HttpStatusCode + Error Content 控制商業流程。

開發環境

  • Windows 11
  • Rider 2023.2
  • ASP.NET Core 7

產生程式碼

Swagger/OpenApi Spec

下載官方提供的 Swagger/OpenApi 的範例檔

我調整了一下範例,/user/{username} 端點多了 x-idempotency-key、x-api-key 兩個 header,但是我產生程式碼的時候,我會把 header 的參數濾掉,改由別的地方集中管理。

 

Generate Server Code 

安裝 NSwag,全域安裝,下列方式隨便挑一個,for Windows

scoop install nswagstudio
winget install RicoSuter.NSwagStudio

 

或是下載壓縮檔,for Windows/Mac

Download latest NSwag command line tools and NSwagStudio (NSwag.zip)

 

範例如下

nswag openapi2cscontroller /input:./openapi.json /classname:PetStore /namespace:Lab.RefitClient.WebAPI.Controllers /output:Lab.RefitClient.WebAPI/Controllers/AutoGenerated/PetStoreController.cs /jsonLibrary:SystemTextJson /useCancellationToken:true /useActionResultType:true /excludedParameterNames:x-idempotency-key,x-api-key

 

Server Code 只有合約 (IPetStoreController),內容是要自己實作,內容如下連結

https://github.com/yaochangyu/sample.dotblog/blob/9fd98dbc7d4fae10306e94f3b9523433c985bd66/WebAPI/Swagger/Lab.RefitClient/Lab.RefitClient.WebAPI/Controllers/AutoGenerated/PetStoreController.cs

 

[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v11.0.0.0))")]
public interface IPetStoreController
{

    /// <summary>
    /// Update an existing pet
    /// </summary>

    /// <remarks>
    /// Update an existing pet by Id
    /// </remarks>

    /// <param name="body">Update an existent pet in the store</param>

    /// <returns>Successful operation</returns>

    System.Threading.Tasks.Task<Pet> UpdatePetAsync(Pet body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));
    ...
}

 

PetStoreController 依賴 IPetStoreController,相關的 route 都已經配置好了

public partial class PetStoreController : Microsoft.AspNetCore.Mvc.ControllerBase
{
    private IPetStoreController _implementation;

    public PetStoreController(IPetStoreController implementation)
    {
        _implementation = implementation;
    }


    /// <summary>
    /// Get user by user name
    /// </summary>
    /// <param name="username">The name that needs to be fetched. Use user1 for testing.</param>
    /// <returns>successful operation</returns>
    [Microsoft.AspNetCore.Mvc.HttpGet, Microsoft.AspNetCore.Mvc.Route("user/{username}")]
    public System.Threading.Tasks.Task<User> GetUserByName(string username, System.Threading.CancellationToken cancellationToken)
    {

        return _implementation.GetUserByNameAsync(username, cancellationToken);
    }
    ...
}

 

Generate Client Code 

安裝 Refitter CLI

dotnet tool install --global Refitter

 

執行 refitter --help,列出所有的參數,如下

USAGE:
   refitter [URL or input file] [OPTIONS]
EXAMPLES:
   refitter ./openapi.json
   refitter https://petstore3.swagger.io/api/v3/openapi.yaml
   refitter ./openapi.json --settings-file ./openapi.refitter
   refitter ./openapi.json --namespace "Your.Namespace.Of.Choice.GeneratedCode" --output ./GeneratedCode.cs
   refitter ./openapi.json --namespace "Your.Namespace.Of.Choice.GeneratedCode" --internal
   refitter ./openapi.json --output ./IGeneratedCode.cs --interface-only
   refitter ./openapi.json --use-api-response
   refitter ./openapi.json --cancellation-tokens
   refitter ./openapi.json --no-operation-headers
   refitter ./openapi.json --no-accept-headers
   refitter ./openapi.json --use-iso-date-format
   refitter ./openapi.json --additional-namespace "Your.Additional.Namespace" --additional-namespace "Your.Other.Additional.Namespace"
   refitter ./openapi.json --multiple-interfaces ByEndpoint
ARGUMENTS:
   [URL or input file]    URL or file path to OpenAPI Specification file
OPTIONS:
                                     DEFAULT
   -h, --help                                         Prints help information
   -s, --settings-file                                Path to .refitter settings file. Specifying this will ignore all other settings
   -n, --namespace                   GeneratedCode    Default namespace to use for generated types
   -o, --output                      Output.cs        Path to Output file
       --no-auto-generated-header                     Don't add <auto-generated> header to output file
       --no-accept-headers                            Don't add <Accept> header to output file
       --interface-only                               Don't generate contract types
       --use-api-response                             Return Task<IApiResponse<T>> instead of Task<T>
       --internal                                     Set the accessibility of the generated types to 'internal'
       --cancellation-tokens                          Use cancellation tokens
       --no-operation-headers                         Don't generate operation headers
       --no-logging                                   Don't log errors or collect telemetry
       --additional-namespace                         Add additional namespace to generated types
       --use-iso-date-format                          Explicitly format date query string parameters in ISO 8601 standard date format using delimiters
                                                      (2023-06-15)
       --multiple-interfaces                          Generate a Refit interface for each endpoint. May be one of ByEndpoint, ByTag

 

範例如下

refitter ./openapi.json --namespace "Lab.RefitClient.GeneratedCode.PetStore" --output ./Lab.RefitClient/PetStoreClient.cs --use-api-response --no-operation-headers --no-auto-generated-header

 

產出來的 Client Code 只有合約,如下:

[System.CodeDom.Compiler.GeneratedCode("Refitter", "0.7.3.0")]
public interface ISwaggerPetstoreOpenAPI30
{
    [Headers("Accept: application/xml, application/json")]
    [Get("/user/{username}")]
    Task<IApiResponse<User>> GetUserByName(string username);

    ...
}

 

完整內容如下連結

https://github.com/yaochangyu/sample.dotblog/blob/9fd98dbc7d4fae10306e94f3b9523433c985bd66/WebAPI/Swagger/Lab.RefitClient/Lab.RefitClient/PetStoreClient.cs

更多的內容請參考官網

https://github.com/christianhelle/refitter

 

最後,用 Taskfile.yml 把上述的指令寫在一起

# Taskfile.yml

version: "3"

tasks:
  codegen:
    desc: codegen client and server
    cmds:
      - task: codegen-client
      - task: codegen-server
  codegen-client:
    desc: codegen client
    cmds:
      - refitter ./openapi.json --namespace "Lab.RefitClient.GeneratedCode.PetStore" --output ./Lab.RefitClient/PetStoreClient.cs --use-api-response --no-operation-headers --no-auto-generated-header  
  codegen-server:
    desc: codegen server
    cmds:
      - nswag openapi2cscontroller /input:./openapi.json /classname:PetStore /namespace:Lab.RefitClient.WebAPI.Controllers /output:Lab.RefitClient.WebAPI/Controllers/AutoGenerated/PetStoreController.cs /jsonLibrary:SystemTextJson /useCancellationToken:true /excludedParameterNames:x-idempotency-key,x-api-key

 

執行結果如下:

 

更多的內容請參考官網

https://taskfile.dev/

實作 IPetStoreController

產出來的 Controller 依賴 IPetStoreController,必須要自己實作 IPetStoreController

 

為了演練,我只完成 GetUserByNameAsync 並且隨便回傳一個結果。

  • IContextGetter<HeaderContext>:封裝了 header 的狀態,只有一個點可以修改 HeaderContext,其餘的點均是唯讀。
  • IHttpContextAccessor:處理 Response,比較好的做法當然就是用 Filter/Middleware
public class PetStoreControllerImpl : IPetStoreController
{
    private readonly IContextGetter<HeaderContext> _contextGetter;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public PetStoreControllerImpl(IContextGetter<HeaderContext> contextGetter, 
        IHttpContextAccessor httpContextAccessor)
    {
        this._contextGetter = contextGetter;
        this._httpContextAccessor = httpContextAccessor;
    }
  
    public async Task<ActionResult<User>> GetUserByNameAsync(string username, CancellationToken cancellationToken = default(CancellationToken))
    {
        // 透過一個 Filter 處理 Header 並轉呈 HeaderContext 物件
        var headerContext = this._contextGetter.Get();

        var response = this._httpContextAccessor.HttpContext.Response;
        response.Headers.Add(PetStoreHeaderNames.IdempotencyKey,headerContext.IdempotencyKey);
        response.Headers.Add(PetStoreHeaderNames.ApiKey,headerContext.ApiKey);
        return new User
        {
            Id = 0,
            Username = username,
            FirstName = null,
            LastName = null,
            Email = "yao@aa.bb",
            Password = null,
            Phone = null,
            UserStatus = 0,
            AdditionalProperties = null
        };
    }
}
public class PetStoreControllerImpl : IPetStoreController
{
    public async Task<User> GetUserByNameAsync(string username, CancellationToken cancellationToken = default(CancellationToken))
    {
        return new User
        {
            Id = 0,
            Username = username,
            FirstName = null,
            LastName = null,
            Email = "yao@aa.bb",
            Password = null,
            Phone = null,
            UserStatus = 0,
            AdditionalProperties = null
        };
    }
}

 

把 header 的內容放到 HeaderContext 物件,只有這個點能夠改變狀態

public class ResolverHeaderContextFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext actionContext)
    {
        var idempotencyKey = actionContext.HttpContext.Request.Headers[PetStoreHeaderNames.IdempotencyKey];
        var apiKey = actionContext.HttpContext.Request.Headers[PetStoreHeaderNames.ApiKey];
        var headerContext = actionContext.HttpContext.RequestServices.GetService<IContextSetter<HeaderContext>>();
        headerContext.Set(new HeaderContext
        {
            IdempotencyKey = idempotencyKey,
            ApiKey = apiKey
        });
    }
}

 

在 DI Container 註冊

builder.Services.AddSingleton<ContextAccessor<HeaderContext>>();
builder.Services.AddSingleton<IContextSetter<HeaderContext>>(p => p.GetService<ContextAccessor<HeaderContext>>());
builder.Services.AddSingleton<IContextGetter<HeaderContext>>(p => p.GetService<ContextAccessor<HeaderContext>>());

 

完整內容如下:

https://github.com/yaochangyu/sample.dotblog/blob/f42d50e12921a9677da6b00e6fe0d674de031592/WebAPI/Swagger/Lab.RefitClient/Lab.RefitClient.WebAPI/Program.cs

 

使用 Refit Client 調用 Web API

最後就是使用方式,Refit 提供了兩種方式,取得 ISwaggerPetstoreOpenAPI30 實例

安裝

dotnet add package Refit.HttpClientFactory --version 7.0.0

 

通過 HttpClientFactory 注入 ISwaggerPetstoreOpenAPI30,再搭配 ConfigureHttpClient 設定 HttpClient 相關設定

/// <summary>
/// F5執行WebApi專案_再呼叫WebApi
/// </summary>
[TestMethod]
public async Task AddRefitClient()
{
    var baseUrl = "https://localhost:7285/api/v3";
    var services = new ServiceCollection();
    services.AddRefitClient<ISwaggerPetstoreOpenAPI30>()
        .ConfigureHttpClient(p =>
        {
            p.DefaultRequestHeaders.Add(PetStoreHeaderNames.IdempotencyKey, "1234567890");
            p.DefaultRequestHeaders.Add(PetStoreHeaderNames.ApiKey, "1234567890");
            p.BaseAddress = new Uri(baseUrl);
        });
    var serviceProvider = services.BuildServiceProvider();
    var client = serviceProvider.GetService<ISwaggerPetstoreOpenAPI30>();
    var username = "yao";
    var response = await client.GetUserByName(username);
    var content = response.Content;
    Assert.AreEqual(username, content.Username);
}

 

通過 RestService.For<T> 和  HttpClient 建立 ISwaggerPetstoreOpenAPI30 實例

[TestMethod]
public async Task RestServiceFor()
{
    var server = new PetStoreTestServer();
    var httpClient = server.CreateClient();
    httpClient.DefaultRequestHeaders.Add(PetStoreHeaderNames.IdempotencyKey, "1234567890");
    httpClient.DefaultRequestHeaders.Add(PetStoreHeaderNames.ApiKey, "1234567890");
    httpClient.BaseAddress = new Uri(httpClient.BaseAddress, "api/v3");
    var client = RestService.For<ISwaggerPetstoreOpenAPI30>(httpClient);
    var username = "yao";

    var response = await client.GetUserByName(username);
    var content = response.Content;
    Assert.AreEqual(username, content.Username);
}

 

通過 HttpRequestMessage 送出請求

Refit 並沒有直接開一個洞可以直接注入 HttpRequestMessage,不過,我們可以從 HttpClientHandler 下手。實作 DelegatingHandler,在建構函數開一個洞,依賴 IContextGetter<HeaderContext> 

public class DefaultHeaderHandler : DelegatingHandler
{
    private readonly IContextGetter<HeaderContext> _contextGetter;

    public DefaultHeaderHandler(IContextGetter<HeaderContext> contextGetter)
    {
        this._contextGetter = contextGetter;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var headerContext = this._contextGetter.Get();
        request.Headers.Add(PetStoreHeaderNames.IdempotencyKey, headerContext.IdempotencyKey);
        request.Headers.Add(PetStoreHeaderNames.ApiKey, headerContext.ApiKey);

        return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
    }
}

 

在 AddRefitClient 注入 RefitSettings 實例

[TestMethod]
public async Task AddRefitClient()
{
    var baseUrl = "https://localhost:7285/api/v3";

    var services = new ServiceCollection();

    services.AddSingleton<ContextAccessor<HeaderContext>>();
    services.AddSingleton<IContextSetter<HeaderContext>>(p => p.GetService<ContextAccessor<HeaderContext>>());
    services.AddSingleton<IContextGetter<HeaderContext>>(p => p.GetService<ContextAccessor<HeaderContext>>());
    services.AddSingleton(p =>
    {
        var settings = new RefitSettings
        {
            HttpMessageHandlerFactory = () =>
                new DefaultHeaderHandler(p.GetService<IContextGetter<HeaderContext>>())
                {
                    InnerHandler = new SocketsHttpHandler()
                },
        };
        return settings;
    });

    services.AddRefitClient<ISwaggerPetstoreOpenAPI30>(p => p.GetRequiredService<RefitSettings>())
        .ConfigureHttpClient(p => { p.BaseAddress = new Uri(baseUrl); })
        ;

    var serviceProvider = services.BuildServiceProvider();
    var contextSetter = serviceProvider.GetService<IContextSetter<HeaderContext>>();
    var client = serviceProvider.GetService<ISwaggerPetstoreOpenAPI30>();
    var username = "yao";

    this.SetHeaderContext(contextSetter);
    var response = await client.GetUserByName(username);
    var content = response.Content;
    Console.WriteLine("get first headers: {0}", response.Headers);
    Assert.AreEqual(username, content.Username);
    Thread.Sleep(1000);
    
    this.SetHeaderContext(contextSetter);
    var response1 = await client.GetUserByName(username);
    var content1 = response1.Content;
    Console.WriteLine("get second headers: {0}", response1.Headers);
    Assert.AreEqual(username, content1.Username);
}

 

更多的內容請參考官網

https://github.com/reactiveui/refit

 

範例位置

sample.dotblog/WebAPI/Swagger/Lab.RefitClient at 9fd98dbc7d4fae10306e94f3b9523433c985bd66 · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo