# 在 ASP.NET Core Web API 中實現可選更新功能
之前我一直無法理解 RESTful 的 `PATCH` 要如何實現。雖然我曾經嘗試使用 `null` 來區分是否要更新某個欄位,但這樣的做法只能適用於字串型別,因為在資料庫中,我會選擇存空字串。因此,只能將欄位的值更新為空字串,而不會將其更新為 `null`。不過,對於像 `DateTime` 這類的 struct 型別,當資料庫允許 `null` 值時,就會遇到無法辨識應該忽略欄位還是將其存儲為 `null` 的問題。
我是不清楚業界的普遍處理方式,我能想到的做法是前後端約定某個特定值來代表不更新該欄位,或者增加一個註記欄位來辨識是否需要進行更新。我個人比較偏好後者的方案。
我的想法是由後端來處理這個註記,而前端則依據是否傳遞指定屬性來判斷是否進行更新。這樣不論是需要可選更新還是必須傳入的屬性,都不會影響到資料結構。
要完成我的想法,需要針對以下幾個方面進行處理:
1. 代表可選屬性的 struct 型別。
2. 如果資料來源是 `[FromBody]`,則需撰寫該型別的 `JsonConverter`。
3. 如果資料來源是 `[FromForm]`,則需撰寫該型別的 `ModelBinder`。
4. Data Annotation 的驗證不是針對該型別,因此需撰寫 `ValueValidator` 來處理。
5. 因為客製化型別處理,所以 Swagger 需要調整產生的 `swagger.json`。
以下將分別說明。
## 可選屬性型別
建立該型別的 struct。這邊使用 struct,而非 class,是因為不需要 `null` 值。此外,當未設置值時,屬性的預設值會是 `OptionalValue<T>()`
,而不是 `null`,這樣能簡化需要處理的判斷邏輯。
```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;
}
}
```
Input DTO 的範例如下:
```csharp
public class Input {
[Required]
public OptionalValue<string> String1 { get; set; }
[Required]
public OptionalValue<string?> String2 { get; set; }
[Required]
[Range(0, 3)]
public OptionalValue<int> Int1 { get; set; }
[Required]
[Range(0, 3)]
public OptionalValue<int?> Int2 { get; set; }
}
```
## FromBody 的 JsonConverter
針對 `OptionalValue<T>` 的 JSON 序列化處理,將序列化的結果從:
```json
{
"string1": {
"hasValue": true,
"value": "Value"
},
"string2": {
"hasValue": false,
"value": null
}
}
```
變更為:
```json
{
"string1": "Value"
}
```
### 自定義 JsonConverter
以下是自定義的 `JsonConverter` 實作:
```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);
}
}
}
```
### JsonConverterFactory
因為自定義的 `JsonConverter` 是泛型型別,所以需要再寫 `JsonConverterFactory`:
```csharp
public class OptionalValueJsonConverterFactory : JsonConverterFactory {
public override bool CanConvert(Type typeToConvert) {
return 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;
}
}
```
### 註冊 JsonConverterFactory
在 `Program.cs` 中加入 `OptionalValueJsonConverterFactory` 的註冊:
```csharp
builder.Services.AddControllers()
.AddJsonOptions(opts => {
opts.JsonSerializerOptions.Converters.Add(new OptionalValueJsonConverterFactory());
});
```
## FromForm 的 ModelBinder
針對 `OptionalValue<T>` 的資料繫結處理,將接收到的格式從以下形式:
```
string1.hasValue=true
string1.value=Value
string2.hasValue=false
string2.value=
```
簡化成:
```
string1=Value
```
### 自定義 ModelBinder
以下是 `OptionalValueModelBinder` 的實作:
```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);
bool isNullable = targetType == typeof(T);
try {
// 理論上 FromForm 不用處理 null,但還是加減處理一下
if (string.IsNullOrEmpty(valueStr)) {
if (isNullable || (!targetType.IsValueType && valueStr is null)) {
bindingContext.Result = ModelBindingResult.Success(new OptionalValue<T?>(default));
return Task.CompletedTask;
}
if (targetType.IsValueType) {
bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"The value '{valueStr}' is invalid.");
return Task.CompletedTask;
}
}
TypeConverter converter = TypeDescriptor.GetConverter(targetType);
object? convertedValue = null;
if (converter.CanConvertFrom(typeof(string))) {
convertedValue = converter.ConvertFrom(valueStr!);
} else {
convertedValue = 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;
}
}
```
### ModelBinderProvider
為了能夠將 `OptionalValue<T>` 類型與相對應的 ModelBinder 綁定,實作了 `OptionalValueModelBinderProvider`:
```csharp
public class OptionalValueModelBinderProvider : IModelBinderProvider {
public IModelBinder? GetBinder(ModelBinderProviderContext context) {
Type modelType = context.Metadata.ModelType;
if (modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(OptionalValue<>)) {
Type valueType = modelType.GetGenericArguments()[0];
Type binderType = typeof(OptionalValueModelBinder<>).MakeGenericType(valueType);
return Activator.CreateInstance(binderType) as IModelBinder;
}
return null;
}
}
```
### 註冊 ModelBinderProvider
在 `Program.cs` 中註冊 `OptionalValueModelBinderProvider`,以便 ASP.NET Core 在處理來自表單的請求時能正確使用此綁定器:
```csharp
builder.Services.AddControllers(options => {
options.ModelBinderProviders.Insert(0, new OptionalValueModelBinderProvider());
});
```
## 處理資料驗證
為了讓 `OptionalValue<T>` 上設定的 `ValidationAttribute` 能夠使用 `Value` 屬性進行驗證,我們需要自定義一個實現 `IModelValidator` 的驗證器。這個驗證器的邏輯如下:
* 當 `HasValue` 屬性為 `false` 時,將忽略驗證。
* 當 `HasValue` 為 `true` 時,則使用 `Value` 屬性進行相應的驗證。
### 自定義 OptionalValueValidator
以下是 `OptionalValueValidator<T>` 的實作範例:
```csharp
public class OptionalValueValidator<T> : IModelValidator {
private readonly ValidatorItem validatorItem;
public OptionalValueValidator(ValidatorItem validatorItem) => this.validatorItem = validatorItem ?? throw new ArgumentNullException(nameof(validatorItem));
public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context) {
if (context.Model is OptionalValue<T> optionalValue) {
if (optionalValue.HasValue) {
List<ModelValidationResult> results = [];
if (validatorItem.ValidatorMetadata is IModelValidator modelValidator) {
results.AddRange(modelValidator.Validate(context));
} else if (validatorItem.ValidatorMetadata is ValidationAttribute attribute) {
ValidationContext validationContext = new(context.Model) {
DisplayName = context.ModelMetadata.GetDisplayName(),
MemberName = context.ModelMetadata.PropertyName
};
if (!attribute.IsValid(optionalValue.Value)) {
results.Add(new ModelValidationResult("", attribute.FormatErrorMessage(validationContext.DisplayName)));
}
}
foreach (ModelValidationResult validationResult in results) {
yield return new ModelValidationResult(validationResult.MemberName, validationResult.Message);
}
}
}
}
}
```
### OptionalValueModelValidatorProvider
以下是 `OptionalValueModelValidatorProvider` 的實作,負責為 `OptionalValue<T>` 型別建立驗證器:
```csharp
public class OptionalValueModelValidatorProvider : IModelValidatorProvider {
public void CreateValidators(ModelValidatorProviderContext context) {
bool isOptionalValueType = context.ModelMetadata.ModelType.IsGenericType
&& context.ModelMetadata.ModelType.GetGenericTypeDefinition() == typeof(OptionalValue<>);
for (int i = 0; i < context.Results.Count; i++) {
ValidatorItem validatorItem = context.Results[i];
if (isOptionalValueType) {
Type valueType = context.ModelMetadata.ModelType.GetGenericArguments()[0];
Type validatorType = typeof(OptionalValueValidator<>).MakeGenericType(valueType);
validatorItem.Validator = Activator.CreateInstance(validatorType, validatorItem) as IModelValidator;
validatorItem.IsReusable = true;
}
}
}
}
```
### 註冊 OptionalValueModelValidatorProvider
最後,在 `Program.cs` 中註冊 `OptionalValueModelValidatorProvider` 以使驗證器能夠被 ASP.NET Core 應用程序使用:
```csharp
builder.Services.AddControllers(opts => {
opts.ModelValidatorProviders.Insert(0, new OptionalValueModelValidatorProvider());
})
```
## 處理 Swagger Schema
因為有客製化 `JsonConverter` 和 `ModelBidner`,為了在 Swagger 文件中正確顯示調整後的結果,需要實作兩個 Filter:`OptionalValueSchemaFilter` 和 `OptionalValueOperationFilter`。這些 Filter 負責修改產出的 `swagger.json` 的型別和參數,使其能夠符合 OptionalValue 的設計。
### OptionalValueSchemaFilter
`OptionalValueSchemaFilter` 主要用於在 Swagger 的 Schema 中,在 `[FromBody]` 的情況下,將 `OptionalValue<T>` 型別的顯示方式調整為只顯示其 `Value` 屬性。以下是實作範例:
```csharp
public class OptionalValueSchemaFilter : ISchemaFilter {
public void Apply(OpenApiSchema schema, SchemaFilterContext context) {
if (context.Type.IsGenericType && context.Type.GetGenericTypeDefinition() == typeof(OptionalValue<>)) {
schema.Type = schema.Properties["value"].Type;
schema.Properties.Clear();
}
}
}
```
### OptionalValueOperationFilter
OptionalValueOperationFilter 用於調整 `[FromForm]` 的請求的參數。以下是該類別的實作範例:
```csharp
public class OptionalValueOperationFilter : IOperationFilter {
public void Apply(OpenApiOperation operation, OperationFilterContext context) {
IList<ApiParameterDescription> parameters = context.ApiDescription.ParameterDescriptions;
if (operation.RequestBody.Content.TryGetValue("multipart/form-data", out OpenApiMediaType? mediaType)) {
IDictionary<string, OpenApiSchema> properties = mediaType.Schema.Properties;
IDictionary<string, OpenApiEncoding> encoding = mediaType.Encoding;
foreach (ApiParameterDescription parameter in parameters) {
if (parameter.Source == BindingSource.Form
&& parameter.ModelMetadata.ContainerType?.IsGenericType == true
&& parameter.ModelMetadata.ContainerType.GetGenericTypeDefinition() == typeof(OptionalValue<>)
) {
if (parameter.Name.EndsWith(".HasValue")) {
string keyToRemove = parameter.Name;
if (properties.ContainsKey(keyToRemove)) {
properties.Remove(keyToRemove);
}
if (encoding.ContainsKey(keyToRemove)) {
encoding.Remove(keyToRemove);
}
}
if (parameter.Name.EndsWith(".Value")) {
string keyToModify = parameter.Name;
string newKey = keyToModify.Replace(".Value", "");
if (properties.TryGetValue(keyToModify, out OpenApiSchema? schema)) {
properties.Remove(keyToModify);
properties.Add(newKey, schema);
RequiredAttribute? requiredAttribute = parameter.ParameterDescriptor.ParameterType
.GetProperty(newKey)?
.GetCustomAttributes<RequiredAttribute>(false)
.FirstOrDefault();
if (requiredAttribute != null && !schema.Required.Contains(newKey)) {
// 有加這行,Swagger 才會顯示必填,但就無法做到沒填值的情境
//mediaType.Schema.Required.Add(newKey);
}
}
if (encoding.TryGetValue(keyToModify, out OpenApiEncoding? apiEncoding)) {
encoding.Remove(keyToModify);
encoding.Add(newKey, apiEncoding);
}
}
}
}
}
}
}
```
:::info
我這邊是將 `[FromBody]` 的處理寫在 `OptionalValueSchemaFilter`,但 `OptionalValueOperationFilter` 調整後,可能也能支援 `[FromBody]` 的處理。
:::
### 註冊 Swagger Filter
將這兩個 Filter 註冊到 Swagger 的服務中,以確保它們在產生 `swagger.json` 時生效:
```csharp
builder.Services.AddSwaggerGen(opts => {
opts.SchemaFilter<OptionalValueSchemaFilter>();
opts.OperationFilter<OptionalValueOperationFilter>();
});
```
產生出來 `swagger.json` 相關內容如下:
```json
{
"paths": {
"/Test/Test1": {
"post": {
"tags": [
"Test"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Input"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/Input"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/Input"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/Test/Test2": {
"post": {
"tags": [
"Test"
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"String1": {
"type": "string"
},
"String2": {
"type": "string"
},
"Int1": {
"type": "integer",
"format": "int32"
},
"Int2": {
"type": "integer",
"format": "int32"
}
}
},
"encoding": {
"String1": {
"style": "form"
},
"String2": {
"style": "form"
},
"Int1": {
"style": "form"
},
"Int2": {
"style": "form"
}
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
}
},
"components": {
"schemas": {
"Input": {
"required": [
"int1",
"int2",
"string1",
"string2"
],
"type": "object",
"properties": {
"string1": {
"$ref": "#/components/schemas/StringOptionalValue"
},
"string2": {
"$ref": "#/components/schemas/StringOptionalValue"
},
"int1": {
"$ref": "#/components/schemas/Int32OptionalValue"
},
"int2": {
"$ref": "#/components/schemas/Int32NullableOptionalValue"
}
},
"additionalProperties": false
},
"Int32NullableOptionalValue": {
"type": "integer",
"additionalProperties": false
},
"Int32OptionalValue": {
"type": "integer",
"additionalProperties": false
},
"StringOptionalValue": {
"type": "string",
"additionalProperties": false
}
}
}
}
```
## 執行結果
使用以下程式碼進行測試:
```csharp
[ApiController]
[Route("[controller]/[action]")]
public class TestController : ControllerBase {
private readonly ILogger<TestController> _logger;
public TestController(ILogger<TestController> logger) {
_logger = logger;
}
[HttpPost]
public void Test1([FromBody] Input forecast) {
}
[HttpPost]
public void Test2([FromForm] Input forecast) {
}
[HttpPost]
public void Test3([FromForm] Input2 forecast) {
}
}
```
### FromBody 結果
如果未傳入任何屬性。

驗證可以通過,但是得到的會是 `OptionalValue<T>.Empty`。

如果有傳入屬性,但值無效。

則會進行驗證。

如果傳入有效值。

則可以得到有值的 `OptionalValue<T>`。

### FromForm 結果
如果未輸入任何值。

驗證可以通過,但是得到的會是 `OptionalValue<T>.Empty`。

如果輸入空值或無效值。

則會進行驗證。

如果傳入有效值。

則可以得到有值的 `OptionalValue<T>`。

###### tags: `.NET` `.NET Core & .NET 5+` `ASP.NET Core` `C#`