FromBody のモデル検証が .NET Core と .NET Framework で違う

.NET Core の場合はデフォルトでモデルの検証処理が入っています。 .NET Framework の場合は入っていません。 .NET Framework から .NET Core に移行するときなどに注意する必要があります。

## .NET 環境

どちらも Web API を使用します。

- .NET Core 3.1
- .NET Framework 4.7.2

## サンプルコード

それぞれで同じものを使えるモデルクラスと、今回の比較対象の Web API Post Method のサンプルコードです。

```csharp
// Request Body に使用するモデルクラス
public class Model
{
    [Required]
    public string a { get; set; }
}
```

```csharp
// .NET Core
// Request されてきた値を JSON 形式で返します。
[HttpPost]
public string Post([FromBody]Model value)
{
    return JsonSerializer.Serialize(value);
}
```

```csharp
// .NET Framework
// こちらも同じように Request されてきた値を JSON 形式で返します。
public string Post([FromBody]Model value)
{
    return JsonConvert.SerializeObject(value);
}
```

## 動作確認

いくつか検証エラーになりそうなパターンで Shell から Request してみると違いがわかります。

### Body に何も設定しない場合

.NET Core の場合はステータスコード 400 と RFC 7807 仕様に準拠したエラーの内容が返ってきます。 .NET Framework の場合はステータスコード 200 と "null" が返ってきます。

```shell
# .NET Core

$ curl --location --request POST 'https://localhost:44370/api/home' --header 'Content-Type: application/json' --data-raw '' -k
# {
#   "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
#   "title": "One or more validation errors occurred.",
#   "status": 400,
#   "traceId": "|cebcea05-40c27a10f8cc8ff5.",
#   "errors": {
#     "": [
#       "A non-empty request body is required."
#     ]
#   }
# }
```

```shell
# .NET Framework

$ curl --location --request POST 'https://localhost:44304/api/home' --header 'Content-Type: application/json' --data-raw '' -k
# "null"
```

### Body に {} を設定した場合

何も設定しない場合と同じステータスコードが返ってきます。エラーの内容は変わっていて、モデルクラスの Required エラーになっています。

```shell
# .NET Core

$ curl --location --request POST 'https://localhost:44370/api/home' --header 'Content-Type: application/json' --data-raw '{}' -k
# {
#   "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
#   "title": "One or more validation errors occurred.",
#   "status": 400,
#   "traceId": "|cebcea05-40c27a10f8cc8ff5.",
#   "errors": {
#       "a": [
#           "The a field is required."
#       ]
#   }
# }
```

```shell
# .NET Framework

$ curl --location --request POST 'https://localhost:44304/api/home' --header 'Content-Type: application/json' --data-raw '{}' -k
# {\"a\":null}
```

### Body の a に null を設定した場合

先程試した {} を設定した場合と同じステータスコードとエラー内容が返ってきます。

```shell
# .NET Core

$ curl --location --request POST 'https://localhost:44370/api/home' --header 'Content-Type: application/json' --data-raw '{"a": null}' -k
# {
#   "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
#   "title": "One or more validation errors occurred.",
#   "status": 400,
#   "traceId": "|cebcea05-40c27a10f8cc8ff5.",
#   "errors": {
#       "a": [
#           "The a field is required."
#       ]
#   }
# }
```

```shell
# .NET Framework

$ curl --location --request POST 'https://localhost:44304/api/home' --header 'Content-Type: application/json' --data-raw '{"a": null}' -k
# {\"a\":null}
```

## まとめ。なんで動作が違うのか

.NET Core には ApiController に対して ModelStateInvalidFilter がデフォルト動作するようになっています。
これによって .NET Framework では自分で用意する必要があった ModelState.IsValid と null のチェックが実装不要になっています。
僕たち開発者にとっては .NET Core のほうが検証処理をデフォルト実装していて便利だと思います。恐らくそういう意見が多くて .NET Core に実装されたのだと考えています。

お互いの動作を合わせるようなコードも載せておきます。細かい複雑な部分など完全な一致ではないので、参考程度にしてください。

### .NET Core のデフォルト検証を外したい場合のコード

Startup.cs で `options.SuppressModelStateInvalidFilter = true` を設定すれば検証しなくなります。

```csharp
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
        .ConfigureApiBehaviorOptions(options =>
        {
            options.SuppressModelStateInvalidFilter = true;
        });
}
```

### .NET Framework で FromBody のデフォルト検証を追加したい場合のコード

ActionFilterAttribute を継承した ModelState と null のチェックをするフィルタークラスを自作して、 WebApiConfig のフィルターに追加します。

```csharp
// FromBody のリクエストパラメーターが null または ModelState.IsValid == false の場合にステータスコード 400 を返すフィルタークラス
// Note: もっと効率のよい実装や、わかりやすい実装があったら教えてください。
public class ModelStateInvalidFilter : ActionFilterAttribute
{
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var fromBodyParameterNames = actionContext.ActionDescriptor.GetParameters()
            .Where(p => p.ParameterBinderAttribute != null && p.ParameterBinderAttribute.Match(new FromBodyAttribute()))
            .Select(descriptor => descriptor.ParameterName);

        foreach (var fromBodyParameterName in fromBodyParameterNames)
        {
            foreach (var requiredArgument in actionContext.ActionArguments.Where(pair => pair.Key == fromBodyParameterName))
            {
                if (requiredArgument.Value == null)
                {
                    actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, "A non-empty request body is required.");
                }
            }
        }

        if (!actionContext.ModelState.IsValid)
        {
            actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, actionContext.ModelState);
        }
    }
}
```

```csharp
// WebApiConfig.cs
public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
        config.Filters.Add(new ModelStateInvalidFilter());

        // 以下、略 ...
    }
}
```

## 参考文献

[ASP.NET Core を使って Web API を作成する | Microsoft Docs](https://docs.microsoft.com/ja-jp/aspnet/core/web-api/?view=aspnetcore-3.1#disable-automatic-400-response)

[aspnetcore/ModelStateInvalidFilter.cs at master · dotnet/aspnetcore · GitHub](https://github.com/dotnet/aspnetcore/blob/master/src/Mvc/Mvc.Core/src/Infrastructure/ModelStateInvalidFilter.cs)