前篇文章實踐以 NSwag 取代 Swashbuckle 為 ASP.NET WebAPI 產生 Swagger 文件及 Swagger UI 線上測試介面的第一步。接著面對之前 Swashbuckle 遇過的老問題 - 如何改以 POST Body 方式傳遞參數,以避免 Query String 參數外露衍生風險,為什麼要讓 ASP.NET WebAPI 2 改用 POST Body 的原理細節請參考前文,本文將聚焦如何改用 NSwag 實現。

首先,為讓 ApiController 比照 ASP.NET MVC 同時支援 Query String 和 Body (application/x-www-form-urlencoded) 取值,一樣需安裝 WebApiContrib NuGet 套件,並在 Controller 加註 [MvcStyleBinding]。

依上回修改 Swashbuckle 的經驗,要將參數定義以 application/x-www-form-urlencoded 傳送,需將 Swagger.json Operation Consumes 修改為 application/x-www-form-urlencoded 並將參數 SwaggerParameterKind 改為 formData。這方面一樣是 ASP.NET Core 支援較完整,直接在 Action 加上 [Consumes("application/x-www-form-urlencoded")],在參數加上 ASP.NET Core Attribute [FromForm] 就可搞定。若是 ASP.NET WebAPI 2,並沒有這些 Attribute 並不存在,所幸查過原始碼發現 NSwag 產生文件時是以 Attribute 名稱識別配合 dynamic 控制 Consumes 及 SwaggerParameterKind,並不依賴強型別物件,這給了一個很簡單的切入點,想辦法自己做一個同名 Attribute 型別,一樣可以產生效果。

所以我在 Models 目錄下新增兩個自訂 Attribute。ConsumesAttribute.cs

using System;

namespace Microsoft.AspNetCore.Mvc
{
    [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public class ConsumesAttribute : Attribute
    {
        public string[] ContentTypes { get; set; }
        public ConsumesAttribute(params string[] contentTypes)
        {
            ContentTypes = contentTypes;
        }
    }
}

FromFormAttribute.cs

using System;

namespace Microsoft.AspNetCore.Mvc
{
    [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
    public class FromFormAttribute : Attribute
    {
        public string Name { get; set; }
    }
}

接著要改寫 CodecController.cs,主要修改點為:

  1. CodecController 加上 [MvcStyleBinding]
  2. EncryptString() 欲改為 POST Body 傳值,故要加上 [Consumes("application/x-www-form-urlencoded")]
  3. encKey, rawText 兩個參數加上 [FromForm]
using System.Collections.Generic;
using System.Web.Http;
using Microsoft.AspNetCore.Mvc;
using WebApiContrib.ModelBinders;
using WebApiDemo.Models;

namespace WebApiDemo.Controllers
{
    /// <summary>
    /// 加解密功能
    /// </summary>
    [MvcStyleBinding]
    public class CodecController : ApiController
    {
        /// <summary>
        /// 加密字串
        /// </summary>
        /// <param name="encKey">加密金鑰</param>
        /// <param name="rawText">明文字串</param>
        /// <returns>加密字串</returns>
        [HttpPost]
        [Consumes("application/x-www-form-urlencoded")]
        public byte[] EncryptString([FromForm]string encKey, [FromForm]string rawText)
        {
            return CodecModule.EncrytString(encKey, rawText);
        }

        /// <summary>
        /// 解密請求參數物件
        /// </summary>
        public class DecryptParameter
        {
            /// <summary>
            /// 加密金鑰
            /// </summary>
            public string EncKey { get; set; }
            /// <summary>
            /// 加密字串陣列
            /// </summary>
            public List<byte[]> EncData { get; set; }
        }

        /// <summary>
        /// 批次解密
        /// </summary>
        /// <param name="decData">解密請求參數(加解密金鑰與加密字串陣列)</param>
        /// <returns>解密字串陣列</returns>
        [HttpPost]
        public List<string> BatchDecryptData([FromBody]DecryptParameter decData)
        {
            return CodecModule.DecryptData(decData.EncKey, decData.EncData);
        }
    }
}

調整後,由 Swagger UI 可驗證我們已成功將 EncryptString() 改造為 application/x-www-form-urlencoded 傳送,而 encKey 與 rawText 參數屬性也已調為 formData,由下方 Curl 範例也可確認參數是以 POST Body UrlEncoded 方式傳送。

不過,Controller 套用 [MvcStyleBinding] 有後遺症,原本 BatchDecryptData() 使用 [FromBody] 讀取 DecryptParameter 型別參數,在套用 MvcStyleBinding 後失效,出現以下錯誤:

Cannot deserialize the current JSON object (e.g. {"name":"value"}) into type 'System.Net.Http.Formatting.FormDataCollection' because the type requires a JSON array (e.g. [1,2,3]) to deserialize correctly.\r\nTo fix this error either change the JSON to a JSON array (e.g. [1,2,3]) or change the deserialized type so that it is a normal .NET type (e.g. not a primitive type like integer, not a collection type like an array or List) that can be deserialized from a JSON object. JsonObjectAttribute can also be added to the type to force it to deserialize from a JSON object.\r\nPath 'EncKey', line 2, position 11.

深入調查發現問題出在 MvcStyleBinding 背後是靠 MvcActionBinding 處理參數繫結,其邏輯未考慮 [FromBody],一律將 POST 內容解析成 FormDataCollection 型別。參考 Stackoverflow 討論,我也決定從修改 MvcActionBinding 邏輯下手,加一段客製邏輯,當參數被標註 [FromBody] 時,改將 POST 內容反序列化為該參數型別:

public override Task ExecuteBindingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
{
    HttpRequestMessage request = actionContext.ControllerContext.Request;
    HttpContent content = request.Content;
    if (content != null)
    {
        HttpParameterDescriptor fromBodyParam = null;
        //偵測是否有任何參數有[FromBody]
        if ((fromBodyParam = actionContext.ActionDescriptor
            .GetParameters().SingleOrDefault(o => o.GetCustomAttributes<FromBodyAttribute>().Any())) != null)
        {
            //若有,將Content內容JSON反列化為參數型別
            var json = content.ReadAsStringAsync().Result;
            var value = JsonConvert.DeserializeObject(json, fromBodyParam.ParameterType);
            var vp = new NameValuePairsValueProvider(new Dictionary<string, object>()
            {
                [fromBodyParam.ParameterName] = value
            }, CultureInfo.CurrentCulture);
            request.Properties.Add(Key, vp);
        }
        else
        {
            FormDataCollection fd = content.ReadAsAsync<FormDataCollection>().Result;
            if (fd != null)
            {
                IValueProvider vp = new NameValuePairsValueProvider(fd, CultureInfo.InvariantCulture);
                request.Properties.Add(Key, vp);
            }
        }
    }

    return base.ExecuteBindingAsync(actionContext, cancellationToken);
}

修改後,[MvcStyleBinding] 與 [FromBody] 就能並存了。

Tips of how to setup ASP.NET WebAPI2 with NSwag to accept appliation/x-www-form-urlencoded content type parameters from post body.


Comments

# by Cloud

前篇文章實踐以 NSwag 取代 Swashbuckle <--- 前篇文章的連結 404

# by Jeffrey

to Cloud, 謝謝,已更正。

Post a comment