.NETでのモデルバリデーション – DataAnnotationsからフィルター活用まで

C#

初めに

Web APIでリクエストを受け取る際、適切な入力チェックを行うことは非常に重要です。
入力値のチェックには、必須項目の確認 や 文字数の制限 などの基本的なものから、複数のプロパティ間のルール まで、さまざまなパターンが考えられます。

C#には DataAnnotations という属性クラスが用意されており、モデルのプロパティに属性を付与することで簡単にチェックを実装できます。
しかし、DataAnnotationsだけでは複雑なバリデーションが難しい場合もあります。

本記事では、

  • DataAnnotationsを活用した基本的なバリデーション
  • 複数のプロパティにまたがるバリデーションの実装
  • エラーメッセージのカスタマイズ
  • フィルターを活用した一括エラーハンドリング

といったポイントを解説し、スマートなバリデーションの実装方法 を紹介します。

前提

以下の環境で動作確認を行っています。

oswindows11
.NET8.0.304
IDEVisual Studio 2022 Community

また、以下を前提としています:

  • .NETでのWEB APIの基礎を理解している。
  • ソースコードは説明に必要な部分のみを抜粋。

サンプルAPI

まずは、銀行にお金を預けるすごく簡易的なAPIを作成します。パラメーターとして以下の4つを受け取ります。これらのパラメーターに対してそれぞれ以下のようなチェック処理を実装します。

  • ID
    • 必須チェック
  • 年齢
    • 年齢チェック(18~100歳)
  • 預金額
    • 金額の最小・最大チェック(1円~100万円)
    • 年齢による最大金額チェック(20未満は30万円まで)
    • 銀行コードによる最大金額チェック(コード99999は1万円まで)
  • 銀行コード
    • 必須チェック
    • 桁数チェック(5桁)

入力パラメータを受け付けるモデルです。

public class FilterModel
{
	public required string ID { get; set; }
	public int Age { get; set; }
	public int Deposit { get; set; }
	public required string BankCode { get; set; }
}

今回は入力値チェックがメインですので、アクションは何もせずOkを返します。

	[HttpPost]
	[Route("/filter")]
	public IActionResult TestFilter([FromBody] FilterModel filterModel) => Ok();

バリデーション処理の実装

基本的なバリデーション:DataAnnotationsを活用

まず、以下のようなAPIを作成し、モデルのバリデーションを行います。
パラメータとして、ID・年齢・預金額・銀行コード の4つを受け取り、それぞれのバリデーションを実装します。

public class FilterModel
{
	[Required(ErrorMessage = $"{nameof(ID)}は必須です")]
	public required string ID { get; set; }
	[Range(18, 100, ErrorMessage = "18~100歳限定")]
	public int Age { get; set; }
	[Range(1, 1_000_000, ErrorMessage = "預金額は1~100万円です")]
	public int Deposit { get; set; }
	[Required(ErrorMessage = "銀行コードは必須です")]
	[Length(5, 5, ErrorMessage = "銀行コードは5桁です")]
	public required string BankCode { get; set; }
}

上記のように、DataAnnotationsを使用することで、

  • Required で必須チェック
  • Range で数値の範囲チェック
  • Length で文字列の長さ制限

などを簡単に設定できます。

複数のプロパティ間のバリデーション:IValidatableObjectを活用

DataAnnotationsは便利ですが、「年齢による預金額の上限チェック」 などの 複数プロパティ間のルール を扱うことはできません。
このようなケースでは、IValidatableObject インターフェース を利用します。

public class FilterModel : IValidatableObject
{
    (略)

	public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
	{
		if (Age < 20 && Deposit > 300_000)
		{
			yield return new ValidationResult("20歳未満は上限30万です", [nameof(Deposit)]);
			yield break;
		}

		if (BankCode == "99999" && Deposit > 10_000)
		{
			yield return new ValidationResult("銀行コード 99999 では上限10万です", [nameof(Deposit)]);
		}
	}
}

上記の Validateメソッド内で、

  • 20歳未満は30万円までしか預金できない
  • 銀行コード99999の口座は1万円までしか預けられない

といった複数の条件を組み合わせたバリデーション を実装しています。なお、Validateメソッドは自動的に呼び出されます。

それでは実際にAPIを実行してエラーチェックを確認します。

18歳が50万を入金の例

{
  "id": "string",
  "age": 18,
  "deposit": 500000,
  "bankCode": "10000"
}

銀行コード99999に20万円を入金する例

{
  "id": "string",
  "age": 28,
  "deposit": 200000,
  "bankCode": "99999"
}

DataAnnotationでの単項目チェックに引っかかった場合は、Validateメソッドは実行されずに400エラーが返却されます。

エラー内容のカスタマイズ

通常、バリデーションエラーが発生すると .NETのデフォルトのエラーメッセージが返却されます。
しかし、エラーメッセージのフォーマットを統一したい場合は、カスタムフィルターを作成 し、エラー内容を任意の形式で返却することができます。

public class CommonError
{
	[JsonPropertyName("ErrorProperty")]
	public string? ID { get; set; }
	[JsonPropertyName("ErrorMessage")]
	public string? ErrorMessage { get; set; }

}

カスタムフィルターの作成

リクエストを受け付けてからアクションが実行されるまでに複数のステージが存在します。フィルターを使うことで特定のステージにおいて任意のコードを実行させることができます。フィルターの詳細については以下の公式サイトをご確認ください。

今回はアクションフィルターを使います。アクションフィルターのOnActionExecutingメソッドはアクション実行前に呼び出されます。

カスタムフィルターを作成するためにはActionFilterAttribute属性を付与した属性クラスを作成します。そしてOnActionExecutingメソッドをオーバーライドします。

public class ValidationFilterAttribute : ActionFilterAttribute
{
	public override void OnActionExecuting(ActionExecutingContext context)
	{
		if (!context.ModelState.IsValid)
		{
			var errorList = new List<CommonError>();
			foreach (var ms in context.ModelState)
			{
				errorList.Add(new CommonError
				{
					ID = ms.Key,
					ErrorMessage = ms.Value.Errors[0].ErrorMessage
				});
			}
			context.Result = new BadRequestObjectResult(errorList);
		}
	}
}

OnActionExecutingメソッドはActionExecutingContextを引数に受け取ります。この時点ですでにモデルでのチェックが実行されており、エラーがある場合はModelState.IsValidtrueが設定されています。ModelStateにはエラー内容が含まれているので、その内容を取り出し好きな形に変換します。

アクションを実行せずエラーで返却したい場合は、ActionExecutingContextResultプロパティにエラー内容を格納します。ResultプロパティはIActionResult型ですので、400エラーとする場合は上記のようにBadRequestObjectResultインスタンスを設定します。

そしてこのフィルターを実行するためにはアクションに属性として付与します。

	[ValidationFilter]
	[HttpPost]
	[Route("/filter")]
	public IActionResult TestFilter([FromBody] FilterModel filterModel) => Ok();

デフォルトフィルターの無効化

アクションに属性を付与してもそのままではカスタムフィルターは実行されません。デフォルトでパラメータにエラーがあった際に400レスポンスを返すフィルターが有効になっているためです。そのためこのデフォルトのフィルターを無効にします。

// Program.cs

builder.Services.AddControllers()
	.ConfigureApiBehaviorOptions(options =>
	{
		options.SuppressModelStateInvalidFilter = true;
	});

これにより、カスタムフィルターが適用され、統一されたフォーマットでエラーメッセージを返却 できるようになります。

確認

それではエラーとなるようなリクエストを送って確認します。

単項目チェックの場合

{
  "id": "string",
  "age": 1001,
  "deposit": 1000000,
  "bankCode": "99999"
}

複数のプロパティをまたがるチェックの場合

{
  "id": "string",
  "age": 100,
  "deposit": 1000000,
  "bankCode": "99999"
}

まとめ

DataAnnotations を活用して簡単にバリデーションを実装できる
IValidatableObject を利用すれば複数プロパティ間のチェックが可能
ActionFilter を用いることでエラーハンドリングを統一できる

本記事では、スマートなバリデーションの実装 を紹介しました。
.NET でバリデーションを行う際に、シンプルなチェックは DataAnnotations、複雑なチェックは IValidatableObject や ActionFilter を活用 すると、より柔軟なバリデーションが実装できます。

コメント