一直以來都是用 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),內容是要自己實作,內容如下連結
[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);
...
}
完整內容如下連結
更多的內容請參考官網
最後,用 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
執行結果如下:
更多的內容請參考官網
實作 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>>());
完整內容如下:
使用 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);
}
更多的內容請參考官網
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET