ASP.NET Core:
Retour automatique en cas d'erreur de validation

Dans le cadre de mon travail, je prépare la migration de nos applications en .NET Framework vers .NET Core. De ce fait, mon analyse couvre aussi les différences entre ASP.NET Core 2.1 (dernière version supportée en .NET Framework) et ASP.NET Core 6 (version à date). Il se trouve que l'une des différences concerne le format de réponse automatique en cas d'erreur de validation. Donc, je me suis plongé dans ce sujet.

Cresonnière
Cresonnière

Bad Request, ou comment convier à reformuler

Selon le standard RFC 7231 :

The 400 (Bad Request) status code indicates that the server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).


Le code de statut de réponse HTTP 400 Bad Request indique que le serveur ne peut pas comprendre ou traiter la requête en raison d'une erreur côté client (par exemple une requête dont la syntaxe ou le contenu est invalide, ou encore que certaines informations sont anormales).

Autrement dit, si un client émet une requête HTTP et que le serveur lui renvoie une réponse avec le code 400 (Bad Request), cela signifie que la requête du client est incorrecte. Le serveur peut intégrer dans sa réponse des informations complémentaires qui peuvent permettre au client de corriger la requête pour ensuite la ré-émettre.

Ainsi, une application qui gère ce type de réponse, peut indiquer à l'utilisateur les champs ayant une valeur saisie incorrecte avec la raison du refus. Alors, l'utilisateur est en mesure de corriger sa saisie et de ré-exécuter l'opération.

Pour cela, il faut que l'application et le serveur soit en accord sur la forme du message. Si le serveur renvoie une réponse qui n'est pas interprétable par l'application, alors cette dernière ne pourra qu'afficher un message d'erreur générique... ce qui est frustrant pour l'utilisateur.

Retour automatique en cas d'erreur de validation

En ASP.NET Core, la validation du modèle est automatique. Par contre, il reste à la charge du développeur de vérifier le résultat de cette validation et retourner la réponse adéquate. Dans la grande majorité des cas, il s'agit de retourner le ControllerBase.ModelState qui comporte les erreurs de validation :

[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required] string label)
    {
        if(!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        return Ok(new { Id = id, Label = label });
    }
}

Par conséquent, la majorité des actions commencent par ce redondant bloc d'instruction :

if(!ModelState.IsValid)
{
    return BadRequest(ModelState);
}

Du moins, devraient... Puisque les développeurs d'ASP.NET Core ont ajouté l'attribut [ApiController] qui facilite le développement d'API en modifiant certain comportement d'ASP.NET Core. Notamment, en retournant automatiquement une réponse 400 (BadRequest) en cas d'erreur de validation du modèle. Ainsi, l'action présentée plutôt peut être simplifiée en :

[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required]string label)
    {
        return Ok(new { Id = id, Label = label });
    }
}

Seulement, comme introduit en préambule, le format de la réponse automatique en cas d'erreur de validation varie en fonction de la version d'ASP.NET Core.

ASP.NET Core 2.1

En ASP.NET Core 2.1, la réponse automatique en cas d'erreur de validation est SerializableError, comme l'indique la documentation :

With a compatibility version of 2.1, the default response type for an HTTP 400 response is SerializableError.


Avec la compatibilité en version 2.1, le type de réponse automatique pour les erreurs HTTP 400 est SerializableError.

SerializableError est un conteneur sérialisable pour ModelStateDictionary. Cette classe permet de convertir ModelStateDictionary en Dictionary<string, string[]>. D'ailleurs, la méthode ControllerBase.BadRequest créait un BadRequestObjectResult qui transforme le ModelStateDictionary en SerializableError :

public abstract class ControllerBase
{
    public virtual BadRequestObjectResult BadRequest(ModelStateDictionary modelState)
        => new BadRequestObjectResult(modelState);
}

public class BadRequestObjectResult : ObjectResult
{
    public BadRequestObjectResult([ActionResultObjectValue] ModelStateDictionary modelState)
        : base(new SerializableError(modelState))
        => StatusCode = StatusCodes.Status400BadRequest;
}

Ainsi, la réponse automatique en cas d'erreur de validation en ASP.NET Core 2.1 est :

[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required]string label)
    {
        return Ok(new { Id = id, Label = label });
    }
}

> curl http://localhost:5000/products/jojo
{
    "id": ["The value 'jojo' is not valid."],
    "label": ["The label field is required."]
}

Ce qui revient à :

[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required] string label)
    {
        if(!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        return Ok(new { Id = id, Label = label });
    }
}

> curl http://localhost:5000/products/jojo
{
    "id": ["The value 'jojo' is not valid."],
    "label": ["The label field is required."]
}

ASP.NET Core 2.2 et supérieur

À partir d'ASP.NET Core 2.2, la réponse automatique en cas d'erreur de validation devient ValidationProblemDetails, comme l'indique la documentation :

With a compatibility version of 2.2 or later, the default response type for an HTTP 400 response is ValidationProblemDetails.


Avec la compatibilité en version 2.2, le type de réponse automatique pour les erreurs HTTP 400 est ValidationProblemDetails.

ValidationProblemDetails permet de formater les erreurs de validation pour générer une réponse respectant RFC7807 (Problem Details for HTTP APIs). Comme SerializableError, cette classe permet de convertir ModelStateDictionary en Dictionary<string, string[]>.

D'ailleurs la méthode ControllerBase.ValidationProblem créait un BadRequestObjectResult qui encapsule ValidationProblemDetails créé à partir du ModelStateDictionary.

Ainsi, la réponse automatique en cas d'erreur de validation en ASP.NET Core 6 est :

[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required]string label)
    {
        return Ok(new { Id = id, Label = label });
    }
}

> curl http://localhost:5000/products/jojo
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-de58e6808a46b7c7483b722009fb5280-416e827d8200cfeb-00",
    "errors": {
        "id": ["The value 'jojo' is not valid."],
        "label": ["The label field is required."]
    }
}

Ce qui revient à :

[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required] string label)
    {
        if(!ModelState.IsValid)
        {
            return ValidationProblem();
        }
        return Ok(new { Id = id, Label = label });
    }
}

> curl http://localhost:5000/products/jojo
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-87e5c74096ac6aa0be307b75d6bdb97a-69b10b85db25e571-00",
    "errors": {
        "id": ["The value 'jojo' is not valid."],
        "label": ["The label field is required."]
    }
}

ModelStateInvalidFilter

ModelStateInvalidFilter est un filtre d'action qui est ajouté à toutes les actions marquées par ApiControllerAttribute. Si le modèle est invalide, il retourne immédiatement une réponse générée par ApiBehaviorOptions.InvalidModelStateResponseFactory :

public class ModelStateInvalidFilter : IActionFilter
{
    private readonly ApiBehaviorOptions _apiBehaviorOptions;

    public ModelStateInvalidFilter(ApiBehaviorOptions apiBehaviorOptions)
    {
        _apiBehaviorOptions = apiBehaviorOptions;
    }

    public void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.Result == null && !context.ModelState.IsValid)
        {
            context.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context);
        }
    }
}

La réponse est générée par l'option ApiBehaviorOptions.InvalidModelStateResponseFactory du service MVC ApiBehaviorOptions.

ApiBehaviorOptions.InvalidModelStateResponseFactory

ApiBehaviorOptions est un service utilisé pour configurer le comportement des types annotés avec ApiControllerAttribute. Sa propriété InvalidModelStateResponseFactory est un délégué référant une conversion du ModelStateDictionary invalide en IActionResult.

En ASP.NET Core 2.1, cette propriété est initialisée avec une méthode qui transforme ModelStateDictionary en SerializableError :

public class ApiBehaviorOptionsSetup : IConfigureOptions<ApiBehaviorOptions>
{
    public void Configure(ApiBehaviorOptions options)
    {
         options.InvalidModelStateResponseFactory = GetInvalidModelStateResponse;

        IActionResult GetInvalidModelStateResponse(ActionContext context)
        {
            var result = new BadRequestObjectResult(context.ModelState);

            result.ContentTypes.Add("application/json");
            result.ContentTypes.Add("application/xml");

            return result;
        }
    }
}

Alors qu'en ASP.NET Core 6, cette propriété est initialisée avec une lambda qui transforme ModelStateDictionary en ValidationProblemDetails :

internal class ApiBehaviorOptionsSetup : IConfigureOptions<ApiBehaviorOptions>
{
    public void Configure(ApiBehaviorOptions options)
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var problemDetails = new ValidationProblemDetails(context.ModelState);
            problemDetails.Status = 400;

            var result = new ObjectResult(problemDetails);
            result.StatusCode = problemDetails.Status
            result.ContentTypes.Add("application/problem+json");
            result.ContentTypes.Add("application/problem+xml");
            
            return result;
        };
    }
}

Aussi, il est possible de surcharger cette propriété pour personnaliser la réponse générée en cas d'erreur de validation. Par exemple en ASP.NET Core 6, pour reproduire le comportement d'ASP.NET Core 2.1 :

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
        {
            options.InvalidModelStateResponseFactory = context =>
            {
                return new BadRequestObjectResult(context.ModelState);
            };
        }
    );

Réponse manuelle/automatique homogène

Il y arrive que certaines réponses en cas d'erreur de validation doivent être manuelles. Dans ce cas, il faut que la réponse manuelle soit conforme à la réponse automatique.

Pour cela, il faut utiliser en ASP.NET Core 2.1 la méthode ControllerBase.BadRequest et en ASP.NET Core 2.2 (et versions suivantes) la méthode ControllerBase.ValidationProblem :

[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id, [Required] string label)
    {
        if (label == "JoJo")
            ModelState.AddModelError(nameof(label), "It must not have the value `JoJo`.");

        if (!ModelState.IsValid)
        {
#if NETCOREAPP2_2_OR_GREATER
            return ValidationProblem();
#else
            return BadRequest(ModelState);
#endif
        }
        return Ok(new { Id = id, Name = label });
    }
}

Une autre possibilité est de générer la réponse manuelle comme est générée la réponse automatique, avec ApiBehaviorOptions.InvalidModelStateResponseFactory :

[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{

    [HttpGet("{id}")]
    public IActionResult Get(
        [FromServices] IOptions<ApiBehaviorOptions> apiBehaviorOptions,
        int id,
        [Required] string label)
    {
        if (label == "JoJo")
        {
            ModelState.AddModelError("label", "It must not have the value `JoJo`.");
        }

        if (!ModelState.IsValid)
        {
            return apiBehaviorOptions.Value.InvalidModelStateResponseFactory(ControllerContext);
        }
        return Ok(new { Id = id, Name = label });
    }
}

Je ne cherche pas à connaître les réponses,
je cherche à comprendre les questions.