Chargement...

Erreurs de validation ASP.NET Core : normaliser ModelState en { field, message }[]

Normalisez les erreurs ModelState via InvalidModelStateResponseFactory au format { field, message }[] afin que le client gère la validation de manière cohérente.

Avec [ApiController], ASP.NET Core valide automatiquement les modèles reçus. Si le body de la requête est invalide, le framework renvoie 400 Bad Request avec un payload de validation. La réponse par défaut est utilisable, mais la structure errors est généralement un dictionnaire (field -> string[]), ce qui impose souvent un traitement “flatten” côté client.

Une approche plus propre consiste à normaliser les erreurs de validation dans un format unique et prévisible : { field, message }[]. Ainsi, côté UI (surbrillance des champs, toasts, liste d’erreurs, etc.), il suffit toujours d’itérer sur le même tableau.


Objectif


Réponse ModelState par défaut (ce que vous obtenez aujourd’hui)

Avec [ApiController], une requête invalide produit souvent un payload comme ceci (simplifié) :


{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": [ "The Email field is not a valid e-mail address." ],
    "Guests": [ "The field Guests must be between 1 and 10." ]
  }
}

Le problème n’est pas le status code, mais la forme (shape) : côté client, il faut généralement transformer ce dictionnaire en liste pour l’afficher proprement.


Format cible (normalisé)

Au lieu d’un dictionnaire, on renvoie une liste simple :


{
  "message": "Échec de validation.",
  "code": "VALIDATION_ERROR",
  "errors": [
    { "field": "email", "message": "The Email field is not a valid e-mail address." },
    { "field": "guests", "message": "The field Guests must be between 1 and 10." }
  ]
}

Remarquez que les noms de champs sont en camelCase. Ce petit détail réduit souvent le travail de mapping côté UI.


Étape 1 : définir un petit DTO d’erreur


public sealed record FieldError(string Field, string Message);

Étape 2 : aplatir ModelState en { field, message }[] (avec une petite touche C# 12)

ModelState stocke les erreurs dans un dictionnaire, et chaque clé peut contenir plusieurs messages. Le helper ci-dessous transforme cela en liste “flat”. Pendant cette conversion, on normalise aussi le nom du champ en camelCase via JsonNamingPolicy.CamelCase.ConvertName(...).


using System.Text.Json;
using Microsoft.AspNetCore.Mvc.ModelBinding;

public static class ModelStateExtensions
{
    public static List<FieldError> ToFieldErrors(this ModelStateDictionary modelState)
    {
        var list = new List<FieldError>();

        foreach (var kvp in modelState)
        {
            // Petite touche C# 12 : normaliser les noms de champs en camelCase
            // (idéal pour des clés simples comme "Email", "Guests")
            var field = JsonNamingPolicy.CamelCase.ConvertName(kvp.Key);

            foreach (var err in kvp.Value.Errors)
            {
                // Si ErrorMessage est vide, fallback sur le message de l’Exception (rare mais possible).
                var msg = !string.IsNullOrWhiteSpace(err.ErrorMessage)
                    ? err.ErrorMessage
                    : err.Exception?.Message ?? "Invalid value.";

                list.Add(new FieldError(field, msg));
            }
        }

        return list;
    }
}

Remarque : si vos clés contiennent des chemins (ex. request.Email ou items[0].Name), il peut être préférable d’appliquer le camelCase par segment. Dans beaucoup d’API, des noms de propriétés simples suffisent, et cette version reste volontairement minimaliste.


Étape 3 : surcharger la réponse de validation globalement (Program.cs)

Le hook global le plus propre est ConfigureApiBehaviorOptions. Quand la validation échoue, ASP.NET Core appelle InvalidModelStateResponseFactory. C’est ici qu’on renvoie notre payload normalisé.


using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState.ToFieldErrors();

            var payload = new
            {
                message = "Échec de validation.",
                code = "VALIDATION_ERROR",
                errors
            };

            return new BadRequestObjectResult(payload);
        };
    });

var app = builder.Build();

app.MapControllers();
app.Run();

À partir de là, toutes les requêtes invalides renverront automatiquement la même liste d’erreurs, sans écrire if (!ModelState.IsValid) dans chaque controller.


Exemple : DTO + endpoint

Avec [ApiController], vous n’avez pas besoin de vérifier ModelState à la main. Si le modèle est invalide, ASP.NET Core renverra automatiquement 400 via notre factory.


using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

public sealed class CreateReservationRequest
{
    [Required, EmailAddress]
    public string Email { get; set; } = "";

    [Range(1, 10)]
    public int Guests { get; set; }
}

[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create([FromBody] CreateReservationRequest request)
    {
        // Invalide ? 400 est renvoyé automatiquement (via InvalidModelStateResponseFactory).
        return Ok(new { message = "Created" });
    }
}

Côté client (beaucoup plus simple)

Comme les erreurs arrivent toujours sous forme de liste, le client peut les afficher de manière uniforme :


const res = await fetch("/api/reservations", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "not-an-email", guests: 100 })
});

if (!res.ok) {
  const payload = await res.json();

  // payload.errors est toujours [{ field, message }]
  for (const e of payload.errors || []) {
    console.log(e.field, e.message);
  }
}

Améliorations courantes


TL;DR

  • [ApiController] valide déjà les modèles et renvoie automatiquement 400.
  • Pour personnaliser globalement : utilisez InvalidModelStateResponseFactory.
  • Aplatissez ModelState en { field, message }[] pour simplifier le code client.
  • Générez des champs en camelCase via JsonNamingPolicy.CamelCase.ConvertName.