筆記目錄

Skip to content

在 ASP.NET Core Web API 中實現可選更新功能

TLDR

  • 透過自定義 OptionalValue<T> struct,可精確區分「不更新欄位」與「更新為 null」。
  • 實作 JsonConverter 處理 [FromBody] 請求,簡化 JSON 序列化格式。
  • 實作 ModelBinder 處理 [FromForm] 請求,解決表單資料繫結問題。
  • 實作 IModelValidator 確保 Data Annotation 驗證機制能正確運作。
  • 透過 ISchemaFilterIOperationFilter 調整 Swagger 文件,確保 API 文件顯示正確的資料結構。

在 RESTful API 的 PATCH 操作中,常見的痛點在於無法區分「不更新該欄位」與「將欄位更新為 null」。若直接使用 null 作為判斷基準,對於 DateTimeint 等 struct 型別將無法區分其原始狀態。本方案透過自定義 OptionalValue<T> 型別,讓後端能明確識別前端是否傳遞了該欄位。

可選屬性型別設計

什麼情況下會遇到這個問題:當 API 需要支援部分更新(Partial Update),且必須區分「欄位未傳遞(忽略)」與「欄位傳遞為 null(更新)」時。

我們使用 readonly record struct 來定義 OptionalValue<T>,利用 HasValue 屬性標記該欄位是否被賦值。

csharp
public readonly record struct OptionalValue<T> {
    private readonly T value;

    public OptionalValue(T value) {
        HasValue = true;
        this.value = value;
    }

    public static OptionalValue<T> Empty() => new();

    [ValidateNever]
    public bool HasValue { get; }

    [ValidateNever]
    public T Value {
        get {
            if (!HasValue) {
                throw new InvalidOperationException("OptionalValue object must have a value.");
            }
            return value;
        }
    }

    public static implicit operator OptionalValue<T>(T value) {
        return new OptionalValue<T>(value);
    }

    public static explicit operator T(OptionalValue<T> value) {
        return value.Value;
    }
}

FromBody 的 JsonConverter 實作

什麼情況下會遇到這個問題:當使用 [FromBody] 接收 JSON 資料時,預設序列化會將 OptionalValue<T> 視為複雜物件,導致前端必須傳遞 {"hasValue": true, "value": "..."} 格式。

透過自定義 JsonConverter,我們可以將 JSON 簡化為直接傳遞值,例如 {"string1": "Value"}

自定義 JsonConverter 與 Factory

csharp
public class OptionalValueConverter<T> : JsonConverter<OptionalValue<T>> {
    public override OptionalValue<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
        if (reader.TokenType == JsonTokenType.None) {
            return OptionalValue<T>.Empty();
        } else {
            T? value = JsonSerializer.Deserialize<T>(ref reader, options);
            if (value is null && typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) is null) {
                throw new JsonException($"Null value is not allowed for non-nullable type {typeof(T)}.");
            }
            return new OptionalValue<T>(value!);
        }
    }

    public override void Write(Utf8JsonWriter writer, OptionalValue<T> value, JsonSerializerOptions options) {
        if (value.HasValue) {
            JsonSerializer.Serialize(writer, value.Value, options);
        }
    }
}

public class OptionalValueJsonConverterFactory : JsonConverterFactory {
    public override bool CanConvert(Type typeToConvert) => 
        typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(OptionalValue<>);

    public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
        Type type = typeToConvert.GetGenericArguments()[0];
        Type converterType = typeof(OptionalValueConverter<>).MakeGenericType(type);
        return Activator.CreateInstance(converterType) as JsonConverter;
    }
}

FromForm 的 ModelBinder 實作

什麼情況下會遇到這個問題:當使用 [FromForm] 接收表單資料時,ASP.NET Core 預設無法正確將表單欄位繫結至泛型 struct。

我們實作 IModelBinder 來處理表單資料的解析,將 key=value 的形式正確對應到 OptionalValue<T>

csharp
public class OptionalValueModelBinder<T> : IModelBinder {
    public Task BindModelAsync(ModelBindingContext bindingContext) {
        ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);

        if (valueProviderResult == ValueProviderResult.None) {
            bindingContext.Result = ModelBindingResult.Success(OptionalValue<T>.Empty());
            return Task.CompletedTask;
        }

        string? valueStr = valueProviderResult.FirstValue;
        Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
        
        try {
            TypeConverter converter = TypeDescriptor.GetConverter(targetType);
            object? convertedValue = converter.CanConvertFrom(typeof(string)) 
                ? converter.ConvertFrom(valueStr!) 
                : Convert.ChangeType(valueStr, targetType);

            bindingContext.Result = ModelBindingResult.Success(new OptionalValue<T>((T)convertedValue!));
        } catch {
            bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"The value '{valueStr}' is invalid.");
        }
        return Task.CompletedTask;
    }
}

處理資料驗證

什麼情況下會遇到這個問題:當 DTO 屬性使用 [Required][Range] 等 Data Annotation 時,預設驗證器無法穿透 OptionalValue<T> 進行內部值的檢查。

我們實作 IModelValidator,當 HasValuetrue 時,才對內部的 Value 進行驗證。

csharp
public class OptionalValueValidator<T> : IModelValidator {
    private readonly ValidatorItem validatorItem;
    public OptionalValueValidator(ValidatorItem validatorItem) => this.validatorItem = validatorItem;

    public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context) {
        if (context.Model is OptionalValue<T> optionalValue && optionalValue.HasValue) {
            if (validatorItem.ValidatorMetadata is ValidationAttribute attribute) {
                if (!attribute.IsValid(optionalValue.Value)) {
                    yield return new ModelValidationResult("", attribute.FormatErrorMessage(context.ModelMetadata.GetDisplayName()));
                }
            }
        }
    }
}

處理 Swagger Schema

什麼情況下會遇到這個問題:由於使用了自定義的 Converter 與 Binder,Swagger 自動產生的文件會顯示錯誤的結構(如顯示 hasValue 欄位)。

透過 ISchemaFilterIOperationFilter,我們能強制 Swagger 顯示正確的型別與參數格式。

  • OptionalValueSchemaFilter:將 [FromBody] 的 Schema 簡化為原始型別。
  • OptionalValueOperationFilter:將 [FromForm] 的參數名稱從 Property.Value 修正為 Property

結論與建議做法

  • 透過 OptionalValue<T> 封裝,能有效解決 API 部分更新的歧義問題。
  • 務必在 Program.cs 中依序註冊 JsonConverterFactoryModelBinderProviderModelValidatorProvider
  • 建議在專案中統一使用此模式處理 PATCH 請求,以維持 API 介面的一致性。

異動歷程

  • 2024-10-21 初版文件建立。