3. Le modèle d'une action
Une action a besoin des informations envoyées par le navigateur : les paramètres de l'URL ([?nom=martin&age=42]), les champs d'un formulaire, les paramètres de route. Le cours d'origine appelle modèle de l'action l'ensemble de ces informations. Ce chapitre montre comment [ASP.NET Core] les fournit à l'action, les convertit dans le bon type et les valide. Ce travail s'appelle la liaison de modèle ([model binding]).
3.1. Exemple 07 : les paramètres de l'URL
Le fichier [Controllers/ParametresController.cs] :
| using Microsoft.AspNetCore.Mvc;
namespace Exemple07.Controllers;
[Route("parametres")]
public class ParametresController : Controller
{
// /parametres/action01?nom=martin : le paramètre [nom] de l'URL -> le paramètre [nom] de l'action
[HttpGet("action01")]
public string Action01(string? nom)
{
return $"nom = [{nom}], de type {nom?.GetType().Name ?? "null"}";
}
// /parametres/action02?age=42 : la valeur est CONVERTIE dans le type du paramètre
[HttpGet("action02")]
public string Action02(int age)
{
return $"age = [{age}], de type {age.GetType().Name}, ModelState.IsValid = {ModelState.IsValid}";
}
// /parametres/action03?age=42 : int? distingue un paramètre absent (null) d'un zéro
[HttpGet("action03")]
public string Action03(int? age)
{
return $"age = [{age?.ToString() ?? "null"}], ModelState.IsValid = {ModelState.IsValid}";
}
// /parametres/action04?page=2 : une valeur par défaut si le paramètre manque
[HttpGet("action04")]
public string Action04(int page = 1)
{
return $"page = [{page}]";
}
// /parametres/action05?q=nest : [FromQuery(Name = ...)] quand les noms diffèrent
[HttpGet("action05")]
public string Action05([FromQuery(Name = "q")] string? recherche)
{
return $"recherche = [{recherche}]";
}
// /parametres/action06?nom=martin&age=42 : tous les paramètres d'un coup
[HttpGet("action06")]
public Dictionary<string, string> Action06()
{
return Request.Query.ToDictionary(p => p.Key, p => p.Value.ToString());
}
}
|
Commentons ce code :
- lignes 10-12 : la liaison de modèle associe les paramètres de l'action aux données de la requête par leur nom : le paramètre [nom] de l'action reçoit la valeur du paramètre [nom] de l'URL (la partie qui suit le [?], appelée chaîne de requête ou [query string]). Le type [string?] indique que la valeur peut manquer : elle vaut alors [null]. [nom?.GetType().Name ?? "null"] : le nom du type de la valeur, ou la chaîne ["null"] si la valeur est [null] (l'opérateur [??] fournit une valeur de remplacement) ;
- lignes 17-19 : le paramètre est déclaré de type [int] : la liaison de modèle convertit la chaîne reçue (["42"]) en nombre. Si la conversion échoue ([?age=abc]), l'action est quand même exécutée, avec la valeur par défaut du type (0 pour un [int]), et l'échec est noté dans [ModelState], l'état de la liaison de modèle : [ModelState.IsValid] vaut alors [false] ;
- lignes 24-26 : avec le type [int?] (un entier « nullable »), un paramètre absent vaut [null], et non 0 : on peut distinguer « pas d'âge » de « âge 0 » ;
- lignes 31-33 : une valeur par défaut [C#] remplace un paramètre absent ;
- lignes 38-40 : l'attribut [[FromQuery(Name = "q")]] précise où chercher la valeur (dans l'URL) et sous quel nom : le paramètre [recherche] de l'action reçoit le paramètre [q] de l'URL ;
- lignes 45-47 : [Request.Query] donne accès à tous les paramètres de l'URL, sans liaison ni conversion. [ToDictionary] ([LINQ]) en fait un dictionnaire, sérialisé en JSON.
Les résultats :
| curl -i "http://localhost:5000/parametres/action01?nom=martin"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
nom = [martin], de type String
|
| curl -i "http://localhost:5000/parametres/action01"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
nom = [], de type null
|
| curl -i "http://localhost:5000/parametres/action02?age=42"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
age = [42], de type Int32, ModelState.IsValid = True
|
| curl -i "http://localhost:5000/parametres/action02?age=abc"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
age = [0], de type Int32, ModelState.IsValid = False
|
| curl -i "http://localhost:5000/parametres/action02"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
age = [0], de type Int32, ModelState.IsValid = True
|
| curl -i "http://localhost:5000/parametres/action03?age=42"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
age = [42], ModelState.IsValid = True
|
| curl -i "http://localhost:5000/parametres/action03"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
age = [null], ModelState.IsValid = True
|
| curl -i "http://localhost:5000/parametres/action03?age=abc"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
age = [null], ModelState.IsValid = False
|
| curl -i "http://localhost:5000/parametres/action04"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
page = [1]
|
| curl -i "http://localhost:5000/parametres/action04?page=2"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
page = [2]
|
| curl -i "http://localhost:5000/parametres/action05?q=aspnet"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
recherche = [aspnet]
|
| curl -i "http://localhost:5000/parametres/action06?nom=martin&age=42"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"nom":"martin","age":"42"}
|
Les guillemets autour de l'URL sont nécessaires quand elle contient le caractère [&], qui a un sens particulier pour la console. On retiendra :
- un paramètre est lié par son nom, sans tenir compte des majuscules ([?NOM=martin] convient aussi) ;
- sa valeur est convertie dans le type déclaré. C'est une différence importante avec [NestJS], où le type d'un paramètre n'était qu'une déclaration [TypeScript], effacée à la compilation ;
- une conversion impossible ne provoque pas d'erreur : l'action est exécutée avec une valeur par défaut, et c'est à elle de consulter [ModelState.IsValid]. Un type « nullable » ([int?]) évite de confondre une valeur absente ou invalide avec 0.
Les caractères accentués et les espaces sont codés dans une URL ([%C3%A9] pour [é]...) : [ASP.NET Core] les décode avant la liaison.

3.2. Exemple 08 : un modèle d'action décrit par une classe
Quand une action a plusieurs paramètres, on les décrit par une classe. Chaque propriété de la classe porte des attributs de validation, fournis par l'espace de noms standard [System.ComponentModel.DataAnnotations]. C'est l'équivalent des décorateurs de la bibliothèque [class-validator] de [NestJS].
Le fichier [Models/PersonneModele.cs] :
| using System.ComponentModel.DataAnnotations;
namespace Exemple08.Models;
// le modèle de l'action : une classe dont chaque propriété porte ses contraintes
public class PersonneModele
{
[Required(ErrorMessage = "le nom est obligatoire")]
[StringLength(20, MinimumLength = 2, ErrorMessage = "le nom doit avoir de 2 à 20 caractères")]
public string? Nom { get; set; }
[Required(ErrorMessage = "l'âge est obligatoire")]
[Range(0, 150, ErrorMessage = "l'âge doit être compris entre 0 et 150")]
public int? Age { get; set; }
[Required(ErrorMessage = "le titre est obligatoire")]
[AllowedValues("Mr", "Mme", "Melle", ErrorMessage = "titre inconnu")]
public string? Titre { get; set; }
// facultatif : pas de [Required]
[EmailAddress(ErrorMessage = "adresse électronique invalide")]
public string? Email { get; set; }
}
|
Commentons ce code :
- ligne 1 : l'espace de noms des attributs de validation ;
- ligne 10 : chaque paramètre attendu est une propriété publique ([{ get; set; }] : on peut la lire et l'écrire). La liaison de modèle crée un objet [PersonneModele], puis remplit chaque propriété avec le paramètre de même nom ;
- lignes 8-9 : le nom est obligatoire ([[Required]]) et doit avoir de 2 à 20 caractères ([[StringLength]]). Chaque attribut accepte un message d'erreur personnalisé ([ErrorMessage]). Les propriétés sont déclarées « nullables » ([string?], [int?]) : un paramètre peut toujours manquer dans une requête, et c'est [[Required]] qui l'exige ;
- lignes 12-14 : l'âge est converti en entier, obligatoire, compris entre 0 et 150 ([[Range]]) ;
- ligne 17 : le titre doit appartenir à une liste de valeurs ([[AllowedValues]], apparu avec [.NET] 8) ;
- ligne 21 : l'adresse électronique est facultative (pas de [[Required]]) ; si elle est présente, elle doit être valide.
Le fichier [Controllers/PersonnesController.cs] :
| using Exemple08.Models;
using Microsoft.AspNetCore.Mvc;
namespace Exemple08.Controllers;
// [ApiController] : une requête dont le modèle est invalide est refusée
// AUTOMATIQUEMENT (erreur 400), avant même l'exécution de l'action
[ApiController]
[Route("personnes")]
public class PersonnesController : Controller
{
// /personnes/action01?nom=martin&age=42&titre=Mr
[HttpGet("action01")]
public object Action01([FromQuery] PersonneModele personne)
{
return new { personne, typeAge = personne.Age?.GetType().Name };
}
}
|
- ligne 6 : l'attribut [[ApiController]] destine le contrôleur à un serveur JSON (une API). Il change plusieurs comportements, dont celui-ci : une requête dont le modèle est invalide est refusée automatiquement, par une erreur 400, avant même l'exécution de l'action. C'est l'équivalent du [ValidationPipe] global de [NestJS] ;
- ligne 14 : l'action reçoit un [PersonneModele], rempli à partir des paramètres de l'URL. Avec [[ApiController]], un paramètre de type classe est cherché par défaut dans le corps de la requête (en JSON) : [[FromQuery]] indique de le chercher dans l'URL ;
- ligne 16 : l'action rend la personne reçue et le type de son âge.
Une requête valide, avec un paramètre [pirate] qui ne correspond à aucune propriété :
| curl -i "http://localhost:5000/personnes/action01?nom=martin&age=42&titre=Mr&pirate=1"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"personne":{"nom":"martin","age":42,"titre":"Mr","email":null},"typeAge":"Int32"}
|
L'âge est bien un nombre, et le paramètre [pirate] a été ignoré : la liaison ne remplit que les propriétés de la classe. Une requête invalide (la réponse JSON a été mise en forme pour la lisibilité) :
| curl -i "http://localhost:5000/personnes/action01?nom=m&age=abc&titre=Dr&email=xx"
|
| HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Age": [
"The value 'abc' is not valid for Age."
],
"Nom": [
"le nom doit avoir de 2 à 20 caractères"
],
"Email": [
"adresse électronique invalide"
],
"Titre": [
"titre inconnu"
]
},
"traceId": "00-9df12af08de9ee725355c4ae8401b5ba-b69e0092d6c23241-00"
}
|
[[ApiController]] a refusé la requête avec une erreur 400, dont le corps JSON, au format standard [Problem Details] (RFC 9110), liste les erreurs par champ. L'action n'a pas été exécutée. L'erreur de conversion de l'âge a un message en anglais, fourni par [ASP.NET Core] ; nous verrons comment le remplacer. Même chose sans aucun paramètre :
| curl -i http://localhost:5000/personnes/action01
|
| HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Age": [
"l'âge est obligatoire"
],
"Nom": [
"le nom est obligatoire"
],
"Titre": [
"le titre est obligatoire",
"titre inconnu"
]
},
"traceId": "00-74956e10244c46f2ab5632a2a5e85829-9a27b50d76da7221-00"
}
|
Ce comportement convient à un serveur JSON, interrogé par un client [JavaScript] qui affiche les erreurs à sa façon. Pour une application HTML, un JSON d'erreur n'a aucun sens : on veut réafficher le formulaire avec ses erreurs. L'exemple suivant montre comment garder la main.
3.3. Exemple 09 : la validation faite par l'action
L'exemple 09 n'utilise pas [[ApiController]]. La classe [PersonneModele] est identique ; seul le contrôleur change. Le fichier [Controllers/PersonnesController.cs] :
| using System.Net;
using Exemple09.Models;
using Microsoft.AspNetCore.Mvc;
namespace Exemple09.Controllers;
// pas de [ApiController] : l'action est TOUJOURS exécutée, et c'est elle qui
// décide de ce qu'elle fait d'un modèle invalide
[Route("personnes")]
public class PersonnesController : Controller
{
[HttpGet("action01")]
public ContentResult Action01(PersonneModele personne)
{
// ModelState : le résultat de la liaison ET de la validation du modèle
if (ModelState.IsValid)
{
return Page($"<p>Bienvenue {personne.Titre} {personne.Nom} ({personne.Age} ans)</p>");
}
// les erreurs, champ par champ
var items = ModelState
.Where(champ => champ.Value!.Errors.Count > 0)
.Select(champ => $"<li>{champ.Key} : {string.Join(", ", champ.Value!.Errors.Select(e => e.ErrorMessage))}</li>");
return Page($"<p>Le formulaire contient des erreurs :</p><ul>{string.Join("", items)}</ul>");
}
// une page HTML (code 200 dans les deux cas)
private ContentResult Page(string html) => Content(html, "text/html; charset=utf-8");
}
|
Commentons ce code :
- ligne 9 : sans [[ApiController]], l'action est toujours exécutée, que le modèle soit valide ou non. Un paramètre de type classe est cherché partout : dans l'URL, dans les champs d'un formulaire, dans la route ;
- ligne 13 : la liaison de modèle et la validation ont été faites avant l'exécution de l'action : l'objet [personne] est rempli, et [ModelState] contient les erreurs éventuelles ;
- lignes 16-18 : pas d'erreur : une page de bienvenue ;
- lignes 21-23 : sinon, la liste des erreurs. [ModelState] est un dictionnaire dont chaque clé est le nom d'un champ ([Nom], [Age]...) et chaque valeur l'état de ce champ, avec sa liste d'erreurs ([Errors]). Les méthodes [LINQ] [Where] (filtrer) et [Select] (transformer) gardent les champs en erreur, et fabriquent une ligne HTML par champ. [!] affirme au compilateur qu'une valeur n'est pas [null] ;
- ligne 24 : une page qui liste les erreurs ;
- ligne 28 : dans les deux cas la réponse est une page HTML, avec le code 200.


Une erreur de conversion apparaît dans [ModelState] comme une erreur de validation :
| curl -i "http://localhost:5000/personnes/action01?nom=martin&age=abc&titre=Mr"
|
| HTTP/1.1 200 OK
Content-Length: 104
Content-Type: text/html; charset=utf-8
<p>Le formulaire contient des erreurs :</p><ul><li>Age : The value 'abc' is not valid for Age.</li></ul>
|
C'est cette technique qu'utiliseront tous les formulaires de l'étude de cas (cf. exemple 19) : l'action teste [ModelState.IsValid], et réaffiche le formulaire s'il y a des erreurs.
3.4. Exemple 10 : les paramètres multiples
Un même paramètre peut apparaître plusieurs fois dans une requête : c'est le cas des cases à cocher d'un formulaire, ou d'une liste à sélection multiple ([?couleur=rouge&couleur=vert]).
Le fichier [Controllers/TableauxController.cs] :
| using Exemple10.Models;
using Microsoft.AspNetCore.Mvc;
namespace Exemple10.Controllers;
[Route("tableaux")]
public class TableauxController : Controller
{
// /tableaux/action01?couleur=rouge&couleur=vert : un paramètre simple
[HttpGet("action01")]
public object Action01(string? couleur)
{
return new { couleur };
}
// /tableaux/action02?couleur=rouge&couleur=vert : un tableau
[HttpGet("action02")]
public object Action02(string[] couleur)
{
return new { couleur, nombre = couleur.Length };
}
// /tableaux/action03?couleur=rouge : une liste, validée par ChoixModele
[HttpGet("action03")]
public object Action03(ChoixModele choix)
{
return new
{
couleurs = choix.Couleur,
valide = ModelState.IsValid,
erreurs = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage),
};
}
}
|
L'action [Action01] (lignes 11-13) montre ce que reçoit un paramètre simple :
| curl -i "http://localhost:5000/tableaux/action01?couleur=rouge"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleur":"rouge"}
|
| curl -i "http://localhost:5000/tableaux/action01?couleur=rouge&couleur=vert"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleur":"rouge"}
|
Un paramètre de type [string] ne reçoit que la première valeur. Un paramètre de type tableau ([Action02], lignes 18-20) les reçoit toutes, qu'il y en ait zéro, une ou plusieurs :
| curl -i "http://localhost:5000/tableaux/action02?couleur=rouge"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleur":["rouge"],"nombre":1}
|
| curl -i "http://localhost:5000/tableaux/action02?couleur=rouge&couleur=vert"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleur":["rouge","vert"],"nombre":2}
|
| curl -i "http://localhost:5000/tableaux/action02"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleur":[],"nombre":0}
|
[ASP.NET Core] évite ainsi un piège classique de [NestJS] ([Express]), où la même variable était une chaîne pour une valeur et un tableau pour plusieurs. Reste à valider la liste. Le fichier [Models/ChoixModele.cs] :
| using System.ComponentModel.DataAnnotations;
namespace Exemple10.Models;
// IValidatableObject : une validation écrite en C#, pour ce que les attributs
// ne savent pas exprimer (ici : CHAQUE élément de la liste)
public class ChoixModele : IValidatableObject
{
private static readonly string[] Couleurs = ["rouge", "vert", "bleu"];
[MinLength(1, ErrorMessage = "choisissez au moins une couleur")]
public List<string> Couleur { get; set; } = [];
public IEnumerable<ValidationResult> Validate(ValidationContext contexte)
{
foreach (var couleur in Couleur.Where(c => !Couleurs.Contains(c)))
{
// yield return : la méthode rend les erreurs une par une
yield return new ValidationResult($"couleur inconnue : {couleur}", [nameof(Couleur)]);
}
}
}
|
Commentons ce code :
- ligne 7 : la classe implémente l'interface [IValidatableObject] : une validation écrite en [C#], pour ce que les attributs ne savent pas exprimer ;
- ligne 11 : [[MinLength(1)]] : la liste doit avoir au moins un élément ;
- lignes 14-18 : la méthode [Validate], appelée par la validation après les attributs, vérifie chaque couleur. Elle rend une erreur ([ValidationResult]) par couleur inconnue, en indiquant le champ concerné. [yield return] rend les erreurs une par une, sans construire de liste.
L'action [Action03] du contrôleur reçoit un [ChoixModele], et rend son contenu avec l'état de la validation :
| curl -i "http://localhost:5000/tableaux/action03?couleur=rouge&couleur=vert"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleurs":["rouge","vert"],"valide":true,"erreurs":[]}
|
| curl -i "http://localhost:5000/tableaux/action03?couleur=rouge&couleur=noir"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleurs":["rouge","noir"],"valide":false,"erreurs":["couleur inconnue : noir"]}
|
| curl -i "http://localhost:5000/tableaux/action03"
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"couleurs":[],"valide":false,"erreurs":["choisissez au moins une couleur"]}
|
3.5. Exemple 11 : les paramètres d'une requête POST
Un formulaire HTML envoyé par la méthode POST place ses champs dans le corps de la requête, et non dans l'URL. Le fichier [Controllers/PostController.cs] :
| using System.Text.Json;
using Exemple11.Models;
using Microsoft.AspNetCore.Mvc;
namespace Exemple11.Controllers;
[Route("post")]
public class PostController : Controller
{
// les champs d'un formulaire HTML (corps application/x-www-form-urlencoded)
[HttpPost("action01")]
public object Action01()
{
return new
{
typeRecu = Request.ContentType,
champs = Request.Form.ToDictionary(c => c.Key, c => c.Value.ToString()),
};
}
// un corps JSON : [FromBody] le désérialise
[HttpPost("action02")]
public object Action02([FromBody] JsonElement corps)
{
return new { typeRecu = Request.ContentType, corps };
}
// un seul champ du formulaire : [FromForm]
[HttpPost("action03")]
public string Action03([FromForm] string? nom)
{
return $"nom = [{nom}]";
}
// les champs du formulaire, liés et validés par la classe PersonneModele
[HttpPost("action04")]
public object Action04([FromForm] PersonneModele personne)
{
return new
{
personne,
valide = ModelState.IsValid,
erreurs = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage),
};
}
}
|
Commentons ce code :
- lignes 12-17 : [Request.Form] contient les champs d'un formulaire HTML (corps de type [application/x-www-form-urlencoded]), sans liaison ni conversion. [Request.ContentType] est le type du corps ;
- lignes 23-25 : [[FromBody]] demande de lire le corps de la requête comme un document JSON (c'est ce qu'envoie un client [JavaScript]). [JsonElement] représente un document JSON quelconque ; on aurait pu déclarer une classe ;
- lignes 30-32 : [[FromForm]] : un champ du formulaire ;
- lignes 37-43 : le formulaire entier, lié et validé par la classe [PersonneModele] de l'exemple 08.
L'option [-d] de [curl] envoie une requête POST avec un corps au format d'un formulaire HTML ([application/x-www-form-urlencoded]) :
| curl -i -d "nom=martin&age=42" http://localhost:5000/post/action01
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"typeRecu":"application/x-www-form-urlencoded","champs":{"nom":"martin","age":"42"}}
|
Le même contenu au format JSON :
| curl -i -H "Content-Type: application/json" -d '{"nom":"martin","age":42}' http://localhost:5000/post/action02
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"typeRecu":"application/json","corps":{"nom":"martin","age":42}}
|
(sous [Windows], dans l'invite de commandes, on écrira [-d "{\"nom\":\"martin\",\"age\":42}"]). L'âge est une chaîne dans le premier cas (un formulaire HTML ne transporte que du texte) et un nombre dans le second. Une action [[FromBody]] refuse un corps qui n'est pas du JSON, par une erreur 415 ([Unsupported Media Type]) :
| curl -i -d "nom=martin&age=42" http://localhost:5000/post/action02
|
| HTTP/1.1 415 Unsupported Media Type
Content-Length: 0
|
Les deux autres actions :
| curl -i -d "nom=martin&age=42" http://localhost:5000/post/action03
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
nom = [martin]
|
| curl -i -d "nom=martin&age=42&titre=Mme" http://localhost:5000/post/action04
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"personne":{"nom":"martin","age":42,"titre":"Mme","email":null},"valide":true,"erreurs":[]}
|
| curl -i -d "nom=martin&age=200" http://localhost:5000/post/action04
|
| HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"personne":{"nom":"martin","age":200,"titre":null,"email":null},"valide":false,"erreurs":["l'âge doit être compris entre 0 et 150","le titre est obligatoire","titre inconnu"]}
|
Une action POST répond avec le code 200, comme une action GET ([NestJS], lui, répondait 201 par défaut). Pour une application HTML, une action POST répond soit par une page (formulaire réaffiché), soit par une redirection (cf. exemple 20).
3.6. Exemple 12 : liaison et validation personnalisées
On peut étendre la liaison de modèle et la validation. L'étude de cas en fait un usage modéré, mais les connaître aide à comprendre le fonctionnement d'[ASP.NET Core].
| 12-liaison-perso/
├── Controllers/
│ └── ExemplesController.cs
├── Liaison/
│ ├── DateFrBinder.cs
│ ├── LangueBinder.cs
│ └── SansEspaceAttribute.cs
├── appsettings.json
├── Exemple12.csproj
└── Program.cs
|
3.6.1. Un model binder : DateFrBinder
Un model binder est l'objet qui fabrique la valeur d'un paramètre à partir de la requête. [ASP.NET Core] en fournit pour tous les types courants ; on peut écrire les siens. Le fichier [Liaison/DateFrBinder.cs] :
| using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Exemple12.Liaison;
// un "model binder" : transforme une date française "23/09/2026" en DateOnly
public class DateFrBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext contexte)
{
// la valeur brute, trouvée dans l'URL, le formulaire, la route...
var brut = contexte.ValueProvider.GetValue(contexte.ModelName).FirstValue;
if (DateOnly.TryParseExact(brut, "dd/MM/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
{
// succès : la valeur que recevra l'action
contexte.Result = ModelBindingResult.Success(date);
}
else
{
// échec : une erreur dans ModelState ("31/02/2026" est refusée aussi)
contexte.ModelState.AddModelError(contexte.ModelName, $"[{brut}] n'est pas une date jj/mm/aaaa valide");
}
return Task.CompletedTask;
}
}
|
- ligne 7 : un model binder implémente l'interface [IModelBinder] ;
- ligne 9 : sa méthode [BindModelAsync] reçoit le contexte de la liaison : le nom du paramètre ([ModelName]), les sources de valeurs ([ValueProvider] : l'URL, le formulaire, la route...), [ModelState]... ;
- ligne 12 : la valeur brute du paramètre ([FirstValue] : la première, s'il y en a plusieurs) ;
- lignes 13-16 : [DateOnly.TryParseExact] convertit une chaîne au format [jj/mm/aaaa] en [DateOnly] (une date sans heure), et rend [false] si c'est impossible, y compris pour une date qui n'existe pas (31/02/2026). En cas de succès, [ModelBindingResult.Success(date)] fournit la valeur que recevra l'action ;
- ligne 21 : en cas d'échec, une erreur est ajoutée à [ModelState] ;
- ligne 23 : la méthode est asynchrone par nature ([Task]), mais n'a rien à attendre : elle rend une tâche déjà terminée.
3.6.2. Un second model binder : LangueBinder
Un paramètre peut venir d'ailleurs que de l'URL ou du formulaire. Le fichier [Liaison/LangueBinder.cs] :
| using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Exemple12.Liaison;
// la langue préférée du navigateur, lue dans l'entête
// "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8" -> "fr"
public class LangueBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext contexte)
{
var entete = contexte.HttpContext.Request.Headers.AcceptLanguage.ToString();
var premiere = entete.Split(',')[0].Trim();
var langue = premiere.Length >= 2 ? premiere[..2].ToLowerInvariant() : "fr";
contexte.Result = ModelBindingResult.Success(langue);
return Task.CompletedTask;
}
}
|
- ligne 11 : l'entête [Accept-Language], qui indique les langues préférées de l'utilisateur ;
- lignes 12-13 : les deux premières lettres de la première langue. [premiere[..2]] est une plage ([range]) : les caractères d'indice 0 et 1.
3.6.3. Un attribut de validation : [SansEspace]
Le fichier [Liaison/SansEspaceAttribute.cs] :
| using System.ComponentModel.DataAnnotations;
namespace Exemple12.Liaison;
// un attribut de validation "maison" : [SansEspace]
public class SansEspaceAttribute : ValidationAttribute
{
public SansEspaceAttribute() : base("le champ {0} ne doit pas contenir d'espace")
{
}
public override bool IsValid(object? valeur)
{
// null est accepté : c'est le rôle de [Required] de l'exiger
return valeur is not string texte || !texte.Contains(' ');
}
}
|
- ligne 6 : un attribut de validation hérite de [ValidationAttribute]. Par convention, le nom d'une classe d'attribut se termine par [Attribute], que l'on omet à l'usage : [[SansEspace]] ;
- ligne 8 : le message d'erreur par défaut, transmis à la classe mère. [{0}] sera remplacé par le nom du champ ;
- lignes 12-15 : la règle. [valeur is not string texte] : si la valeur n'est pas une chaîne ([null], par exemple), elle est acceptée ; sinon, elle est affectée à la variable [texte], qui ne doit pas contenir d'espace. L'étude de cas a un attribut de ce genre, [[Parmi]].
3.6.4. Le contrôleur
Le fichier [Controllers/ExemplesController.cs] :
| using Exemple12.Liaison;
using Microsoft.AspNetCore.Mvc;
namespace Exemple12.Controllers;
[Route("exemples")]
public class ExemplesController : Controller
{
// /exemples/agenda?jour=23/09/2026 : un model binder personnalisé
[HttpGet("agenda")]
public IActionResult Agenda([ModelBinder(typeof(DateFrBinder))] DateOnly jour)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState[nameof(jour)]!.Errors[0].ErrorMessage);
}
return Ok($"agenda du jour [{jour:yyyy-MM-dd}]");
}
// /exemples/bonjour : un paramètre qui ne vient pas de l'URL, mais d'un entête
[HttpGet("bonjour")]
public string Bonjour([ModelBinder(typeof(LangueBinder))] string langue)
{
return langue == "en" ? "Hello!" : "Bonjour !";
}
// /exemples/recherche?motCle=aspnet : un attribut de validation personnalisé
[HttpGet("recherche")]
public IActionResult Recherche([SansEspace] string? motCle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState[nameof(motCle)]!.Errors[0].ErrorMessage);
}
return Ok($"recherche de [{motCle}]");
}
}
|
- ligne 11 : l'attribut [[ModelBinder]] désigne le model binder à utiliser pour ce paramètre ;
- ligne 15 : une date invalide donne une erreur 400, avec le message du model binder ;
- ligne 22 : le paramètre [langue] est fourni par le second model binder ;
- ligne 29 : un attribut de validation peut aussi s'appliquer directement à un paramètre d'action.
Les résultats :
| curl -i "http://localhost:5000/exemples/agenda?jour=23/09/2026"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
agenda du jour [2026-09-23]
|
| curl -i "http://localhost:5000/exemples/agenda?jour=2026-09-23"
|
| HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
[2026-09-23] n'est pas une date jj/mm/aaaa valide
|
| curl -i "http://localhost:5000/exemples/agenda?jour=31/02/2026"
|
| HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
[31/02/2026] n'est pas une date jj/mm/aaaa valide
|
| curl -i -H "Accept-Language: fr-FR,fr;q=0.9" http://localhost:5000/exemples/bonjour
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Bonjour !
|
| curl -i -H "Accept-Language: en-US,en;q=0.9" http://localhost:5000/exemples/bonjour
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Hello!
|
(l'option [-H] de [curl] ajoute un entête à la requête)
| curl -i "http://localhost:5000/exemples/recherche?motCle=aspnet"
|
| HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
recherche de [aspnet]
|
| curl -i "http://localhost:5000/exemples/recherche?motCle=asp%20net"
|
| HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
le champ motCle ne doit pas contenir d'espace
|
([%20] est le codage d'un espace dans une URL)
3.6.5. Conclusion
Le modèle d'une action est construit par la liaison de modèle à partir de la requête : paramètres de route, de l'URL, champs du formulaire, corps JSON, entêtes. Les valeurs sont converties dans le type des paramètres, puis validées par les attributs. Le résultat de ces deux étapes est dans [ModelState]. Pour un serveur JSON, [[ApiController]] refuse automatiquement un modèle invalide ; pour une application HTML, l'action teste elle-même [ModelState.IsValid] (exemple 09), afin de pouvoir réafficher le formulaire. C'est l'objet du chapitre suivant.