自訂 Query Strings 轉換格式

Query Strings 預設格式如 https://localhost:<port>/purchaseorders?purchaseordernumber=PO12345678,此範例說明如何轉換為 SnakeCase,即https://localhost:<port>/purchaseorders?purchase_order_number=PO12345678

範例

  1. 建立 ASP.NET Core Web API (.NET 8) 專案

  2. 新增 DTO (Data Transfer Object)

    封裝 Query Strings

    1
    2
    3
    4
    namespace Purchasing.UseCases.Dtos
    {
    public record PurchaseOrderDto(DateTime? StartDate, DateTime? EndDate);
    }
  3. 新增 API Controller

    Response 的部分不影響這個範例,所以省略實作細節。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    using Microsoft.AspNetCore.Mvc;
    using Purchasing.UseCases.Dtos;

    namespace Purchasing.Api.Controllers
    {
    [Route("[controller]")]
    [ApiController]
    public class PurchaseOrdersController : ControllerBase
    {
    [HttpGet]
    public async Task<IActionResult> ListPurchaseOrdersAsync([FromQuery] PurchaseOrderDto dto)
    {
    // ...
    }
    }
    }
  4. 實作 SnakeCase 的轉換方法

    讀者可以自行實作,在這邊筆者直接參考 Newtonsoft.Json/Utilities/StringUtils.cs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    using System.Text;

    namespace Purchasing.Api.ModelBinding
    {
    internal static class SnakeCaseQueryStringValueExtensions
    {
    private enum SeparatedCaseState
    {
    Start,
    Lower,
    Upper,
    NewWord
    }

    internal static string ToSnakeCase(this string s) => ToSeparatedCase(s, '_');

    internal static string ToKebabCase(string s) => ToSeparatedCase(s, '-');

    private static string ToSeparatedCase(string s, char separator)
    {
    if (string.IsNullOrEmpty(s))
    {
    return s;
    }

    StringBuilder sb = new StringBuilder();
    SeparatedCaseState state = SeparatedCaseState.Start;

    for (int i = 0; i < s.Length; i++)
    {
    if (s[i] == ' ')
    {
    if (state != SeparatedCaseState.Start)
    {
    state = SeparatedCaseState.NewWord;
    }
    }
    else if (char.IsUpper(s[i]))
    {
    switch (state)
    {
    case SeparatedCaseState.Upper:
    bool hasNext = i + 1 < s.Length;
    if (i > 0 && hasNext)
    {
    char nextChar = s[i + 1];
    if (!char.IsUpper(nextChar) && nextChar != separator)
    {
    sb.Append(separator);
    }
    }
    break;
    case SeparatedCaseState.Lower:
    case SeparatedCaseState.NewWord:
    sb.Append(separator);
    break;
    }

    char c;
    #if HAVE_CHAR_TO_LOWER_WITH_CULTURE
    c = char.ToLower(s[i], CultureInfo.InvariantCulture);
    #else
    c = char.ToLowerInvariant(s[i]);
    #endif
    sb.Append(c);

    state = SeparatedCaseState.Upper;
    }
    else if (s[i] == separator)
    {
    sb.Append(separator);
    state = SeparatedCaseState.Start;
    }
    else
    {
    if (state == SeparatedCaseState.NewWord)
    {
    sb.Append(separator);
    }

    sb.Append(s[i]);
    state = SeparatedCaseState.Lower;
    }
    }

    return sb.ToString();
    }
    }
    }
  5. 新增 SnakeCaseQueryStringValueProvider

    繼承 QueryStringValueProvider,覆寫 GetValue(string key),把 key 轉換為 SnakeCase ,其中 key 為 DTO 的 Property Name。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    using Microsoft.AspNetCore.Mvc.ModelBinding;
    using System.Globalization;

    namespace Purchasing.Api.ModelBinding
    {
    internal class SnakeCaseQueryStringValueProvider : QueryStringValueProvider
    {
    public SnakeCaseQueryStringValueProvider(
    BindingSource bindingSource,
    IQueryCollection values,
    CultureInfo? culture)
    : base(bindingSource, values, culture)
    {
    }

    public override ValueProviderResult GetValue(string key)
    {
    return base.GetValue(key.ToSnakeCase());
    }
    }
    }
  6. 新增 SnakeCaseQueryStringValueProviderFactory

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    using Microsoft.AspNetCore.Mvc.ModelBinding;
    using System.Globalization;

    namespace Goldtek.Services.Workflow.Api.ModelBinding
    {
    internal class SnakeCaseQueryStringValueProviderFactory : IValueProviderFactory
    {
    public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
    {
    _ = context ?? throw new ArgumentNullException(nameof(context));

    var query = context.ActionContext.HttpContext.Request.Query;
    if (query?.Count > 0)
    {
    context.ValueProviders.Add(
    new SnakeCaseQueryStringValueProvider(
    BindingSource.Query,
    query,
    CultureInfo.CurrentCulture));
    }

    return Task.CompletedTask;
    }
    }
    }
  7. Program.cs 配置 SnakeCaseQueryStringValueProviderFactory

    取代預設的 ValueProviderFactory

    1
    2
    3
    4
    5
    6
    7
    8
    9
    builder.Services.AddControllers(options =>
    {
    var index = options.ValueProviderFactories.IndexOf(
    options.ValueProviderFactories.OfType<QueryStringValueProviderFactory>()
    .Single());

    options.ValueProviderFactories[index] =
    new SnakeCaseQueryStringValueProviderFactory();
    });

References