Swagger 背後的生態系統豐富,為享受寫好 WebAPI 後用現成工具自動產生文件、測試網頁、客戶端程式庫的便利,我準備調整 WebAPI 的開發策略。

過去我主要是用 ASP.NET MVC Controller 實作 WebAPI,即(範例教學:使用 ASP.NET MVC 打造 WebAPI 服務文章提到的做法, 再客製用 T4 寫文件、客戶端產生器,少量使用或自己玩還 OK,要推廣給同事,還是回歸 Swagger 標準比較簡便。

我偏好非主流的 RPC-Style 風格,不走 RESTful。(理由可參考:閒聊 - Web API 是否一定要 RESTful?), 不用 HTTP 方法區隔而是以名稱區別作業項目,而 ASP.NET ApiController 偏向 RESTful,要寫成 RPC-Style 需要一些調整, 故過去開發時我都用一般 MVC Controller 實作以求省事,但要結合 Swagger,首先遇到的問題便是 Swagger 工具如 Swashbuckle、NSwag 等都是為 ApiController 量身訂做(畢竟這才是主流)。 ApiController 要寫成 RPC-Style 不是不可能,但是有些眉角,過去被我逃避掉,現在只能乖乖面對。

第一個遇到的問題在Swagger 初試筆記提過,RPC-Style 會先遇到同一 ApiController 多個 HttpPost 方法並存的問題,在 Swashbuckle 可加上 [Route("api/MyApiName/DoSomething")] 解決。

再來,便是參數傳遞問題。

依據文件 Parameter Binding in ASP.NET Web API, Web API 處理參數繫結的原則如下:

  • 簡單型別參數由 URI 取得,例如 .NET 的基本型別(Primitive Type) 如 int、bool、double 等等再加上 TimeSpan、DateTime、Guid、decimal,字串,以及可透過型別轉換器(Type Converter)轉為字串的型別。
  • 複雜型別參數則使用 Media-Type Formatter 從訊息本體(Message Body)轉換讀取。

例如這個範例:

HttpResponseMessage Put(int id, Product item) { ... }

id 將從 URI 讀取(?id=...),item 則由訊息 Body 傳入,而透過 [FromUri] 及 [FromBody] Atrribute,可以改變來源,其組合可整理成下表。

參數型別繫結來源
Primitive (基本型別:string, int, bool, DateTime... )Query String
Complex (複雜型別:物件)Request Body
[FromBody] PrimitiveRequest Body
[FromUri] PrimitiveQuery String

參考來源

在 ASP.NET MVC 裡,Action 參數同時支援 URL 查詢字串和 Body 取值,若想在 ApiController 支援相同的彈性,需使用 [MvcStyleBinding] Attribute。 關於這部分蔡煥麟老師有篇詳細實測比較可以參考: ASP.NET Web API 參數繫結 (註:MvcStyleBinding 目前收藏在 WebApiContrib NuGet 套件裡)

然而,對於多個簡單參數, ApiController 預設會以 Query String 方式傳遞,但我仍偏好 application/x-www-form-urlencoded 內容型別,以 POST Body 傳送 p1=...&p2=... 方式。 最重要的理由是可以避免參數值曝露在 URL,想像一下,如果當參數包含個資或密碼,你網站的 IIS Log 可就「價值連城」了,成為黑市的搶手貨... 這點光想到就讓人頭皮發麻。 (順便資安宣導:當心營運資訊裸奔-網站偵錯 Log 檔常犯的資安錯誤) 面對這個問題,一個簡單方法是將多參數定義成參數物件 { account: "xxx", password: "zzz" },ApiController 即會轉用 POST Body 傳送。 但為了將就 ApiController 傳送方式而定義一次性參數物件、調整介面,有違 KISS 原則。頑固的程式老魔人,決心要找出更簡便的解法,以下便是奮鬥到三更半夜的成果(好久沒這麼熱血了)。

好消息是 ASP.NET Core 一舉又擴充了 [FromHeader]、[FromForm]、[FromRoute]、[FromeQuery]、[FromBody]、[FromServices] 等選擇 (延伸閱讀:ASP.NET Core 2 系列 - Model Binding) ,套用 [FromForm] 即可解決這個問題,但實務上有不少專案還沒法改用 ASP.NET Core 開發,所以我的問題變成 「如何在 .NET 4.5 ASP.NET WebAPI 2 讓 ApiController 實現 [FromForm] ?」

爬文件外加在 Swashbuckle 與 NSwag CodeGen 原始碼探險,總算研究出解法。

Swagger.json 中的 Parameter 物件有個 in 屬性,由 NSwag.SwaggerParameterKind 查到它包含 body、query、path、header、formData、modelbinding 等選擇。再由 NSwag CodeGen 原始碼推導出當 Operation Consume 為 application/x-www-form-urlencoded 且 SwaggerParameterKind 為 formData 時,客戶端便會以 p1=...&p2=... POST Body 方式傳送。換言之,只要設法在 Swagger.json Operation consumes 加入 application/x-www-form-urlencoded,並將 Parameter in 屬性設成 formData 即可如我所願。

Swachbuckle 有個 IOperationFilter 機制,開放產生 Swagger.json 時加入自訂邏輯。我先定義一個 SwaggerFromFormAttribute,可標註參數應以 POST Body 傳送,再自訂一個實作 IOperationFilter 的類別,在 Apply() 方法裡, 會檢視 Operation 的所有參數,尋找是有標註 [SwaggerFromForm] Attribute 的項目,若有,則將其 @in 屬性覆寫為 "formData",同時在 Operation.comsumes 集合加上 "application/x-www-form-urlencoded"。完整程式範例如下:

using Swashbuckle.Swagger;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Http.Description;

namespace SampleWebApi.Models
{
    public class SwaggerFromFormAttribute : Attribute
    {
    }

    public class FromFormOperationFilter : IOperationFilter
    {
        public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription)
        {
            if (operation.parameters != null)
            {
                bool hasFromFormParam = false;
                var formParams = apiDescription.ActionDescriptor.GetParameters()
                    .Where(p => p.GetCustomAttributes<SwaggerFromFormAttribute>().Any())
                    .Select(p => p.ParameterName);
                operation.parameters
                    .Where(p => formParams.Contains(p.name))
                    .ToList().ForEach(p =>
                    {
                        p.@in = "formData";
                        hasFromFormParam = true;
                    });
                if (hasFromFormParam)
                {
                    List<string> consumes = operation.consumes?.ToList() ?? new List<string>();
                    consumes.Add("application/x-www-form-urlencoded");
                    operation.consumes = consumes;
                }
            }
        }
    }
}

在 App_Start/SwaggerConfig.cs SwaggerConfig.Register() 註冊 c.OperationFilter<FromFormOperationFilter>();

下一步是試著在 ApiController 方法參數加上 [SwaggerFromForm] 觀察效果。做三個 Action 都有 p1, p2 參數,測試完全不標、只標 p2、p1 與 p2 都標上 [SwaggerFromForm],看看結果有何不同。

[MvcStyleBinding]
public class MarathonController : ApiController
{
    [HttpPost]
    [Route("api/Marathon/Action1")]
    public string Action1(string p1, string p2)
    {
        return $"p1={p1}, p2={p2}";
    }

    [HttpPost]
    [Route("api/Marathon/Action2")]
    public string Action2(string p1, [SwaggerFromForm] string p2)
    {
        return $"p1={p1}, p2={p2}";
    }

    [HttpPost]
    [Route("api/Marathon/Action3")]
    public string Action3([SwaggerFromForm] string p1, [SwaggerFromForm] string p2)
    {
        return $"p1={p1}, p2={p2}";
    }
}

以下是 Swagger UI 產生的測試介面,透過自訂 [SwaggerFromForm] Attribute,我們成功將 Action2 的 p2,及 Action3 的 p1, p2 參數的 Parameter Type 改為 formData:

而實測 Action2,URL 有 ?p1=ABC,Content-Type 為 application/x-www-form-urlencoded,傳送內容為 p2=123,符合預期。

而用 NSwag Studio 產生的 C# 客戶端程式碼如下,p1 以 URL 傳送,p2 先形成 KeyValuePair<string, string> 再轉 FormUrlEncodedContent,正是我們所需結果。

public async System.Threading.Tasks.Task<string> Action2Async(string p1, string p2, System.Threading.CancellationToken cancellationToken)
{
    if (p1 == null)
        throw new System.ArgumentNullException("p1");

    var urlBuilder_ = new System.Text.StringBuilder();
    urlBuilder_.Append(BaseUrl != null ? BaseUrl.TrimEnd('/') : "").Append("/api/Marathon/Action2?");
    urlBuilder_.Append("p1=").Append(System.Uri.EscapeDataString(ConvertToString(p1, System.Globalization.CultureInfo.InvariantCulture))).Append("&");
    urlBuilder_.Length--;

    var client_ = _httpClient;
    try
    {
        using (var request_ = new System.Net.Http.HttpRequestMessage())
        {
            var keyValues_ = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, string>>();
            if (p2 == null)
                throw new System.ArgumentNullException("p2");
            else
                keyValues_.Add(new System.Collections.Generic.KeyValuePair<string, string>("p2", ConvertToString(p2, System.Globalization.CultureInfo.InvariantCulture)));
            request_.Content = new System.Net.Http.FormUrlEncodedContent(keyValues_);
            request_.Method = new System.Net.Http.HttpMethod("POST");
            request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));

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

就這樣,我朝 ApiController 實現 RPC Style WebAPI 的目標再推進了一步。

WebAPI 2 read primitive parameter from URI and complext type parameter from POST body by default, this ariticle provide a way to send multiple parameters via WebAPI POST body with Swashbuckle and NSwag CSharp client.


Comments

# by 余小章

同命方法多個參數,還可以這樣

# by 余小章

同命方法多個參數,還可以這樣 https://dotblogs.com.tw/yc421206/2019/01/19/solve_swagger_not_supported_multiple_operations

# by Jeffrey

to 余小章,感謝分享。

Post a comment