ASP.Net Core : BindRequired... Une fausse bonne idée

L'article précédent présentait la principale limitation de l'attribut [Required] en ASP.NET Core. Pour combler cette limitation, l'attribut [BindRequired] a été ajouté en renfort. Mais est-ce vraiment salvateur?

Deux chemins, un choix
Deux chemins, l'un incommode mais ayant fait ses preuves, l'autre pratique... mais avec une déplaisante surprise

Pour résumer brièvement le précédent article, l'attribut [Required] permet d'assurer la présence d'un paramètre en entrée d'une action d'un contrôleur, mais dans le cas d'un paramètre d'entrée de type complexe il fallait l'utiliser uniquement avec des propriétés de type nullable. Donc, on se retrouve à déclarer nullable des propriétés requises par définition non-nullable... Un peu contradictoire, non?

Heureusement, en ASP.NET Core l'attribut [BindRequired] vient à notre rescousse :

[ApiController]
[Route("data")]
public class DataController : ControllerBase
{
    [HttpGet]
    public IActionResult Get([FromQuery]Data data)
    {
        return Ok(data);
    }

    public class Data
    {
        [BindRequired]
        public int Id { get; set; }
        [BindRequired]
        public string Value { get; set; }
    }
}

Ainsi, on retrouve le comportement désiré :

> curl http://localhost:5000/data
HTTP response status code 400 - Bad Request
Content :
{
    "errors": {
        "Id": [
            "A value for the 'Id' parameter or property was not provided."
        ],
        "Value": [
            "A value for the 'Value' parameter or property was not provided."
        ]
    }
}

Et cela fonctionne aussi pour des paramètres simples :

[ApiController]
[Route("data")]
public class DataController : ControllerBase
{
    [HttpGet]
    public IActionResult Get([BindRequired] int id, [BindRequired] string value)
    {
        return Ok(new { id, value });
    }
}
> curl http://localhost:5000/data
HTTP response status code 400 - Bad Request
Content :
{
    "errors": {
        "id": [
            "A value for the 'id' parameter or property was not provided."
        ],
        "value": [
            "A value for the 'value' parameter or property was not provided."
        ]
    }
}

Le message d'erreur n'est pas exactement le même qu'avec [Required], mais il reste similaire. Puis le code de retour est 400 (Bad Request), ce qui est le principal. La solution serait d'utiliser l'attribut [BindRequired] au lieu de l'attribut [Required].

Mais comme le laissé préjugé le titre de l'article, [BindRequired] a aussi ces défauts. Premièrement il provient du package NuGet Microsoft.AspNetCore.Mvc.Core, une dépendance lourde si le modèle est réutilisé en dehors d'application Web.

Autre problème, l'attribut ne fonctionne pas dans certains cas :

[ApiController]
[Route("data")]
public class DataController : ControllerBase
{
    [HttpPost]
    public IActionResult Post([FromBody] Data data)
    {
        return Ok(data);
    }

    public class Data
    {
        [BindRequired]
        public int Id { get; set; }
        [BindRequired]
        public string Value { get; set; }
    }
}
> curl http://localhost:5000/data -H  "Content-Type: application/json" -d "{}"
HTTP response status code 200 - OK
Content :
{
    "id": 0,
    "value": null
}

Comme l'indique la documentation :

Note that this [BindRequired] behavior applies to model binding from posted form data, not to JSON or XML data in a request body. Request body data is handled by input formatters.
Notez que ce comportement de [BindRequired] s’applique à la liaison de modèle des données de formulaire postées, et non aux données JSON ou XML d’un corps de requête. Les données du corps de requête sont prises en charge par les formateurs d’entrée.

Lorsque les paramètres proviennent du contenu de la requête sous forme JSON ou XML, alors c'est le désérialiseur qui s'occupe d'initialiser les paramètres. Seulement ces derniers n'ont pas connaissance de l'attribut [BindRequired] et l'ignorent. Comme avec [Required], [BindRequired] ne fonctionne pas dans tous les cas.

En outre, le développeur se retrouve à jongler entre plusieurs attributs pour un besoin similaire, ce qui entraine de la confusion. Si vous en doutez, voici une liste non exhaustive de ticket ouvert à l'équipe ASP.NET Core de Microsoft à ce sujet :

Mais le problème majeur, c'est que le problème initial n'est pas résolu. Il reste des cas où la vérification n'est pas effective. [BindRequired] ne permet pas de vérifier la présence d'un paramètre de type non-nullable, si ce dernier provient du contenu de la requête sous forme JSON ou XML. Dans ce cas, il faut utiliser les fonctionnalités du désérialiseur.

Par exemple avec le désérialiseur est Json.Net, il faut utiliser l'attribut [JsonProperty] avec l'option Required.Always :

[ApiController]
[Route("data")]
public class DataController : ControllerBase
{
    [HttpGet]
    public IActionResult Get([FromQuery] Data data)
    {
        return Ok(data);
    }

    [HttpPost]
    public IActionResult Post([FromBody] Data data)
    {
        return Ok(data);
    }

    public class Data
    {
        [JsonProperty(Required = Required.Always)]
        public int Id { get; set; }
        [JsonProperty(Required = Required.Always)]
        public string Value { get; set; }
    }
}
> curl http://localhost:5000/data -H  "Content-Type: application/json" -d "{}"
HTTP response status code 400 - Bad Request
Content :
{
    "errors": {
        "id": [
            "Required property 'id' not found in JSON. Path '', line 1, position 2."
        ],
        "value": [
            "Required property 'value' not found in JSON. Path '', line 1, position 2."
        ]
    }
}

Et pour chaque désérialiseur c'est différent... Quand ils le permettent, car certains désérialiseur n'ont pas cette fonctionnalité. Par exemple, la récente bibliothèque System.Text.Json, qui remplace Json.NET, ne dispose pas de cette fonctionnalité. Du moins pas en version 5 , mais la fonctionnalité est en cours de discussion pour une probable futur implémentation.

En conclusion [Required] et [BindRequired] ont leurs propres défauts, mais je conseille d'uniquement utiliser [Required] et rendre les propriétés requises nullable. [Required] a l'avantage d'intervenir à la validation, donc d'être isolé des mécanismes d'interprétation de la requête. Finalement, la bonne vieille solution reste d'actualité, en plus d'être compatible avec le classique ASP.NET.

Il ne faut pas multiplier les épithètes sans nécessité; car tout mot qui n'est pas nécessaire nuit à la liaison.