📌

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 を使った標準的なバリデーションの実装です。

モデルクラスの定義

SampleRequest.cs
public class SampleRequest
{
    [Required(ErrorMessage = "コードは必須です")]
    public string Code { get; set; } = "";

    [Required(ErrorMessage = "名前は必須です")]
    public string Name { get; set; } = "";

    [EmailAddress(ErrorMessage = "メールアドレスの形式が不正です")]
    public string MailAddress { get; set; } = "";
}

コントローラーの実装

SampleController.cs
[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 を削除します:

SampleRequest.cs
public class SampleRequest
{
    public string Code { get; set; } = "";
    public string Name { get; set; } = "";
    public string MailAddress { get; set; } = "";
}

Validator クラスの作成

SampleRequestValidator.cs
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)); // 空でない場合のみ検証
    }
}

コントローラーの更新

SampleController.cs
[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);
    }
}

拡張メソッドの作成(オプション)

毎回同じコードを書くのを避けるため、拡張メソッドを作成すると便利です:

ControllerExtensions.cs
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 登録

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
// FluentValidationのValidatorを自動登録
builder.Services.AddValidatorsFromAssemblyContaining<SampleRequestValidator>();

var app = builder.Build();
// ...

3. 課題:クライアント側での利用

問題点

サーバー側では便利な ValidationProblemDetails ですが、そのままクライアント側で使用すれば良いと思っていました。しかし、問題がありました。

Client側で試したコード
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. 解決策:カスタムクラスの作成

クライアント用のエラークラス定義

ApiError.cs
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;
            }
        }
    }
}

クライアント側の実装

Program.cs
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 パッケージの参照が必要

実践での推奨アプローチ

  1. サーバー側: ValidationProblemDetails を使用してエラーレスポンスを統一
  2. クライアント側: 専用のエラークラスを定義してデシリアライズ

今回学んだこと

  • ValidationProblemDetails はサーバー側では優れた選択肢
  • FluentValidation と組み合わせることで、より柔軟なバリデーションが可能
  • クライアント側では制限があるため、適切な対応が必要

この方法により、サーバー側では標準的な方法を使いつつ、クライアント側でも扱いやすいエラー処理を実現できました。

脚注
  1. Microsoft Docs: ValidationProblemDetails(ASP.NET Core 8.0) https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.aspnetcore.mvc.validationproblemdetails?view=aspnetcore-8.0 ↩︎

  2. FluentValidation Docs: Automatic Validation の解説 https://docs.fluentvalidation.net/en/latest/aspnet.html#automatic-validation ↩︎

  3. FluentValidation Docs: Manual Validation の解説 https://docs.fluentvalidation.net/en/latest/aspnet.html#manual-validation ↩︎

  4. サードパーティ拡張: SharpGrip.FluentValidation.AutoValidation(自動検証の方法) https://docs.fluentvalidation.net/en/latest/aspnet.html#using-a-filter ↩︎

Discussion