ValidationProblemDetailsとFluentValidationを使ってみた+クライアント側も
はじめに
ASP.NET Core の Web API では、エラーレスポンスの標準化として RFC7807 準拠の ProblemDetails
が用意されています。さらに、バリデーションエラー専用の ValidationProblemDetails
[1] というクラスも存在し、これを使うことでフィールドごとのエラーメッセージを構造化して返すことができます。
本記事では、ValidationProblemDetails
の基本的な使い方、最新のFluentValidation
と組み合わせた実装方法、そしてクライアント側の実装について説明します。
ProblemDetails と ValidationProblemDetails の違い
// ProblemDetails: 一般的なエラー情報
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"status": 403,
"detail": "Your current balance is 30, but that costs 50."
}
// ValidationProblemDetails: フィールドごとのバリデーションエラー
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { // ← この部分が追加されている
"Name": ["名前は必須です"],
"Email": ["メールアドレスの形式が不正です"]
}
}
なぜ FluentValidation と組み合わせるのか
ASP.NET Core の標準バリデーション(DataAnnotations)も便利ですが、FluentValidation を使うことで:
- 複雑なバリデーションロジックをクラスとして分離できる
- テストが書きやすい
- 条件分岐を含むバリデーションが実装しやすい
- エラーメッセージのカスタマイズが柔軟
これらの利点を活かしつつ、ValidationProblemDetails
で統一的なエラーレスポンスを返したいというのが今回の目的です。
環境
- .NET 8
- FluentValidation 12.0.0
- FluentValidation.DependencyInjectionExtensions 12.0.0
1. ASP.NET Core 標準の Validation
まずは、DataAnnotations を使った標準的なバリデーションの実装です。
モデルクラスの定義
public class SampleRequest
{
[Required(ErrorMessage = "コードは必須です")]
public string Code { get; set; } = "";
[Required(ErrorMessage = "名前は必須です")]
public string Name { get; set; } = "";
[EmailAddress(ErrorMessage = "メールアドレスの形式が不正です")]
public string MailAddress { get; set; } = "";
}
コントローラーの実装
[ApiController]
[Route("[controller]")]
public class SampleController : ControllerBase
{
[HttpPost("register")]
public ActionResult<SampleRequest> Register([FromBody] SampleRequest request)
{
// ApiControllerAttributeにより、自動的にモデル検証が行われる
// 検証エラーがある場合、自動的にValidationProblemDetailsが返される
return Ok(request);
}
}
テスト実行
以下のような不正なデータを送信してみます:
{
"code": "",
"name": "name",
"mailAddress": "mailaddress.com"
}
レスポンス結果
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Code": ["コードは必須です"],
"MailAddress": ["メールアドレスの形式が不正です"]
},
"traceId": "00-00000000000000000000000000000000-0000000000000000-00"
}
ApiController
属性により、自動的に ValidationProblemDetails
形式でエラーが返されています。
2. FluentValidation との統合
次に、FluentValidation を使って同じ機能を実装してみます。
パッケージのインストール
Install-Package FluentValidation
Install-Package FluentValidation.DependencyInjectionExtensions
モデルクラスの更新
DataAnnotations を削除します:
public class SampleRequest
{
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public string MailAddress { get; set; } = "";
}
Validator クラスの作成
public class SampleRequestValidator : AbstractValidator<SampleRequest>
{
public SampleRequestValidator()
{
RuleFor(x => x.Code)
.NotEmpty().WithMessage("コードは必須です");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("名前は必須です");
RuleFor(x => x.MailAddress)
.EmailAddress().WithMessage("メールアドレスの形式が不正です")
.When(x => !string.IsNullOrEmpty(x.MailAddress)); // 空でない場合のみ検証
}
}
コントローラーの更新
[ApiController]
[Route("[controller]")]
public class SampleController : ControllerBase
{
private readonly IValidator<SampleRequest> _validator;
public SampleController(IValidator<SampleRequest> validator)
{
_validator = validator;
}
[HttpPost("register")]
public async Task<ActionResult<SampleRequest>> RegisterAsync([FromBody] SampleRequest request)
{
// Manual Validationの実行
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
// ValidationProblemDetailsを返すための処理
validationResult.Errors.ForEach(error =>
ModelState.AddModelError(error.PropertyName, error.ErrorMessage)
);
return ValidationProblem(); // ValidationProblemDetailsを返す
}
return Ok(request);
}
}
拡張メソッドの作成(オプション)
毎回同じコードを書くのを避けるため、拡張メソッドを作成すると便利です:
public static class ControllerExtensions
{
// ここ注意
// System.ComponentModel.DataAnnotations.ValidationResult ではない
public static ActionResult ToValidationProblem(
this FluentValidation.Results.ValidationResult validationResult,
ControllerBase controller)
{
if (validationResult.IsValid)
{
throw new InvalidOperationException("Validation result is valid");
}
validationResult.Errors.ForEach(error =>
controller.ModelState.AddModelError(error.PropertyName, error.ErrorMessage)
);
return controller.ValidationProblem();
}
}
使用例:
if (!validationResult.IsValid)
{
return validationResult.ToValidationProblem(this);
}
DI 登録
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// FluentValidationのValidatorを自動登録
builder.Services.AddValidatorsFromAssemblyContaining<SampleRequestValidator>();
var app = builder.Build();
// ...
3. 課題:クライアント側での利用
問題点
サーバー側では便利な ValidationProblemDetails
ですが、そのままクライアント側で使用すれば良いと思っていました。しかし、問題がありました。
using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5052/") };
var response = await client.PostAsJsonAsync("sample/register", sample);
if (response.StatusCode == HttpStatusCode.BadRequest)
{
// ❌ これは期待通りに動作しない
var vpd = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
// Errorsプロパティが空になってしまう!
Console.WriteLine(vpd.Errors.Count); // 0
}
なぜ問題なのか
ValidationProblemDetails
の Errors プロパティは読み取り専用で、JSON デシリアライズ時に値が設定されません:
public class ValidationProblemDetails : ProblemDetails
{
// getterのみでsetterがない!
public IDictionary<string, string[]> Errors { get; } = new Dictionary<string, string[]>();
}
さらに、ValidationProblemDetails
を使うには Microsoft.AspNetCore.Mvc.Core
パッケージをクライアント側で参照する必要があり、これは設計上好ましくありません。
4. 解決策:カスタムクラスの作成
クライアント用のエラークラス定義
public class ApiError
{
public string? Type { get; set; }
public string? Title { get; set; }
public int? Status { get; set; }
public string? Detail { get; set; }
public string? Instance { get; set; }
public string? TraceId { get; set; }
public Dictionary<string, object?>? Extensions { get; set; }
// Errorsプロパティにsetterがある
public Dictionary<string, string[]>? Errors { get; set; }
// ヘルパーメソッド
public bool HasErrors => Errors?.Count > 0;
public IEnumerable<string> GetAllErrorMessages()
{
if (Errors == null)
{
yield break;
}
foreach (var fieldErrors in Errors.Values)
{
foreach (var error in fieldErrors)
{
yield return error;
}
}
}
}
クライアント側の実装
internal static class Program
{
private static async Task Main(string[] args)
{
using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5052/") };
var sample = new SampleRequest
{
Code = "",
Name = "name",
MailAddress = "mailaddress.com"
};
var response = await client.PostAsJsonAsync("sample/register", sample);
if (!response.IsSuccessStatusCode)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var apiError = await response.Content.ReadFromJsonAsync<ApiError>(options);
if (apiError?.HasErrors == true)
{
Console.WriteLine("バリデーションエラー:");
foreach (var (field, errors) in apiError.Errors!)
{
Console.WriteLine($" {field}:");
foreach (var error in errors)
{
Console.WriteLine($" - {error}");
}
}
}
}
}
}
実行結果:
バリデーションエラー:
Code:
- コードは必須です
MailAddress:
- メールアドレスの形式が不正です
まとめ
ValidationProblemDetails の利点と制限
利点:
- RFC7807 準拠の標準的なエラーフォーマット
- フィールドごとのエラーを構造化して返せる
- ASP.NET Core に組み込まれており、追加パッケージ不要
-
ApiController
属性と組み合わせると自動的に生成される
制限:
- クライアント側でのデシリアライズに制限がある
- Errors プロパティが読み取り専用
- クライアントで使用するには ASP.NET Core パッケージの参照が必要
実践での推奨アプローチ
-
サーバー側:
ValidationProblemDetails
を使用してエラーレスポンスを統一 - クライアント側: 専用のエラークラスを定義してデシリアライズ
今回学んだこと
-
ValidationProblemDetails
はサーバー側では優れた選択肢 - FluentValidation と組み合わせることで、より柔軟なバリデーションが可能
- クライアント側では制限があるため、適切な対応が必要
この方法により、サーバー側では標準的な方法を使いつつ、クライアント側でも扱いやすいエラー処理を実現できました。
-
Microsoft Docs: ValidationProblemDetails(ASP.NET Core 8.0) https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.aspnetcore.mvc.validationproblemdetails?view=aspnetcore-8.0 ↩︎
-
FluentValidation Docs: Automatic Validation の解説 https://docs.fluentvalidation.net/en/latest/aspnet.html#automatic-validation ↩︎
-
FluentValidation Docs: Manual Validation の解説 https://docs.fluentvalidation.net/en/latest/aspnet.html#manual-validation ↩︎
-
サードパーティ拡張: SharpGrip.FluentValidation.AutoValidation(自動検証の方法) https://docs.fluentvalidation.net/en/latest/aspnet.html#using-a-filter ↩︎
Discussion