ModelBinder avec ASP .Net Core
Comment respecter les principes de la programmation orientée objet dans vos pages web
Présentation
Lorsque vous concevez une application ASP .NET Core classique, que ce soit en MVC ou en Razor pages, il arrive un moment où vous souhaitez soumettre des données. Comme vous êtes consciencieux vous validez ces données à l’entrée du contrôleur avant de les traiter. Et comme vous faites les choses bien, vous utilisez le ModelState
et les décorateurs de validation
pour valider les données en question.
Pour ceux qui sont déjà perdus à ce stade, je vous invites à consulter la doc: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation
Pour la suite de cet exemple, je vais utiliser le cas d’une page de connexion, où l’on doit saisir un e-mail pour s’authentifier.
Donc quand on utilise le ModelState
etc. le code généralement va ressembler à ça:
Controller :
[HttpPost("login"), AllowAnonymous, ValidateAntiForgeryToken]
public async Task<IActionResult> LoginPost(LoginViewModel viewModel)
{
if (!ModelState.IsValid)
return View(viewModel);
ApplicationUser? user = await _userManager.FindByNameAsync(viewModel.Email);
UserName = user?.UserName ?? viewModel.Email;
if (user != null)
{
UserId = user.Id;
Firstname = user.FirstName;
return RedirectToAction("Password", new { viewModel.ReturnUrl, user.FirstName });
}
return RedirectToAction("Register", new { viewModel.ReturnUrl });
}
ViewModel :
public class LoginViewModel
{
[EmailAddress]
public string Email { get; set; }
[Url]
public string? ReturnUrl { get; set; }
}
View :
@model LoginViewModel
<form method="post" asp-route-returnUrl="@Model.ReturnUrl">
@Html.AntiForgeryToken()
<section>
<div class="input-group">
<div asp-validation-summary="All" class="text-validation"></div>
</div>
<div class="input-group">
<input type="email" asp-for="@Model.Email" class="border-bottom-dark" autofocus required autocomplete="off" />
<label asp-for="@Model.Email">Email</label>
</div>
</section>
<footer>
<button class="btn btn-full-width btn-ok">Connexion</button>
</footer>
</form>
Voilà, ça c’est ce que vous allez trouver partout à longueur de tutos d’exemples etc. sur le net, et c’est un problème.
Pourquoi ce code est un problème ?
Oui, ce code, bien que court, est truffé de problèmes.
Dans le contrôleur :
if (!ModelState.IsValid)
return View(viewModel);
Vous avez entendu parler du pattern POST - Redirect - GET ? Non ? C’est le moment.
Quand vous faites un POST dans un formulaire, si le serveur vous fait un retour direct comme ici en cas d’erreur, si vous faites F5 dans votre navigateur, celui-ci va vouloir rejouer le POST au lieu de juste rafraichir la page. c’est comme ça que vous obtenez la fameuse pop-in qui vous demande si vous êtes sûr de vouloir renvoyer le formulaire alors que vous souhaitez juste rafraichir la page. Post/Redirect/Get - Wikipedia
Dans la ViewModel :
public class LoginViewModel
{
[EmailAddress]
public string Email { get; set; }
[Url]
public string? ReturnUrl { get; set; }
}
Là on touche à la logique Orienté Objet. Cette classe n’est pas un objet. C’est juste un POCO, Plain Old CLR Object, une structure de données sans aucune logique dedans.
Pourtant on devine bien qu’il y a de la logique derrière avec les décorateurs [EmailAddress]
et [Url]
mais cette logique est déportée ailleurs dans un validateur externe.
Ne vous faites pas avoir par le décorateur
[EmailAddress]
il ne valide absolument pas que vous avez un e-mail valide. Il vérifie juste que votre e-mail contient bien un @ et que ce n’est ni le premier ni le dernier caractère. Un peu léger. Si vous voulez valider un e-mail c’est par ici : RFC 2822 - Internet Message Format (ietf.org)
Dans la vue :
La vue est la moins problématique. Les textes y sont écrits en dur, donc pour les changer facilement ou proposer du multilingue c’est loupé. En dehors de ça, ça va, on a l’antiforgery token ce qui est quand même le plus important.
Passons à l’orienté Objet
Si maintenant nous changeons de manière de construire tout ça et que l’on introduit de vrais objets, qu’est-ce que ça donne ?
Prenons l’email par exemple. Là c’est juste un string avec un validateur externe on ne sait trop où. Il n’y a donc aucune logique la dedans. On fait du fonctionnel avec un langage objet. Tout le monde le fait, moi le premier. C’est très facile de tomber dedans, surtout quand tous les exemples que l’on voit partout reproduisent ce schéma.
Faire vraiment de l’orienté objet nécessite un effort intellectuel dans la conception de son code. Mais le jeu en vaut la chandelle.
Nous allons donc modifier notre classe LoginViewModel comme ceci:
public class LoginViewModel
{
public LoginViewModel()
{
Email = Email.Empty;
}
public LoginViewModel(Uri returnUrl) : this()
{
ReturnUrl = returnUrl;
}
public LoginViewModel(Email email, Uri returnUrl) : this(returnUrl)
{
Email = email;
}
public Email Email { get; set; }
public Uri? ReturnUrl { get; set; }
}
L’utilisation des constructeurs n’est pas obligatoire ici puisqu’il y a des setter, mais c’est quand même une bonne habitude à avoir. Pourquoi il y a des setter ? Tout simplement parce que le Framework va devoir faire un bind des valeurs du formulaire sur la classe et sans setter pas de binding.
Ce qui est nettement plus intéressant c’est le remplacement des string par une classe Email
et par une classe Uri
. La classe Uri
, c’est une classe du framework, pas grand chose à dire dessus si ce n’est lisez la doc : https://learn.microsoft.com/fr-fr/dotnet/api/system.uri
La classe Email
c’est une implémentation maison:
public class Email
{
private string _email;
public Email(string email)
{
_email = email;
}
}
On a donc notre classe qui encapsule la valeur et qui en est maitre puisque cette valeur ne peut être modifiée depuis l’extérieur de la classe.
Tout ça pour ça ?!?
Non bien sûr. Maintenant que nous avons un objet qui maitrise sa donnée, on va pouvoir lui donner un comportement vis à vis de cette donnée. A commencer par sa validation.
Pour la validation des données via le ModelState, on peut utiliser les décorateurs sur des primitives comme vu tout à l’heure ou on peut implémenter un validateur directement dans la classe.
Pour cela là classe doit implémenter IValidatableObject
.
public partial class Email : IValidatableObject
{
private const string _pattern = @"^((?<!\.)([a-zA-Z0-9\&\'\*\+\-\/\=?\^_\{\}\~]\.?)+(?<!\.))@((?<!\.)([a-zA-Z0-9\&\'\*\+\-\/\=?\^_\{\}\~]\.?)+(?<!\.))\.([a-zA-Z]+)$";
private string _email;
public Email(string email)
{
_email = email;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!Validate())
yield return new ValidationResult("Invalid e-mail address");
}
public bool Validate() => EmailValidationRegEx().IsMatch(_email);
[GeneratedRegex(_pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant)]
private static partial Regex EmailValidationRegEx();
On implémente nous même la mécanique de validation de l’e-mail dans la classe e-mail et c’est utilisable pour valider les soumissions de formulaire. C’est pour ça qu’il y a deux signatures différentes à la méthode Validate()
. L’une sert exclusivement à retourner un résultat de validation au ModelState
(qui n’est qu’un gros dictionnaire soit dit en passant) et l’autre peut être appelée de n’importe où dans l’application.
Vous pouvez ajouter en plus la propriété Empty
:
public static Email Empty => new(string.Empty);
qui permet de faire cet appel dans la ViewModel
:
public LoginViewModel()
{
Email = Email.Empty;
}
C’est toujours plus clair dans le code puisque ça véhicule une intention.
C’est déjà pas mal tout ça, mais ça ne fonctionne pas encore.
La méthode du contrôleur contient ceci:
ApplicationUser? user = await _userManager.FindByNameAsync(viewModel.Email);
Problème: la signature de la méthode FindByNameAsync()
ne prend pas un Email
en argument, mais un string
. On ne pourrait même pas caster la valeur comme ceci:
ApplicationUser? user = await _userManager.FindByNameAsync((string)viewModel.Email);
Il faudrait impérativement faire un override
de la méthode ToString():
public override string ToString() => _email;
ApplicationUser? user = await _userManager.FindByNameAsync(viewModel.Email.ToString());
Il y a mieux. Ca alourdi le code, il faut le refaire à chaque fois, bref ça manque clairement d’élégance. Le seul intérêt, et c’est pour cela que l’on va garder cet override
, c’est que la méthode ToString()
ne retourne pas le nom de la classe au lieu de la valeur de l’email. Pour le reste il y a les cast implicites:
public static implicit operator string(Email email) => email._email;
public static implicit operator Email(string email) => new(email);
De cette manière nous avons un cast implicte dans les deux sens:
Quand on passe un Email
là où un string
est attendu, c’est automatiquement la valeur du champ qui est retournée. A l’inverse si on passe un string là où un Email
est attendu, une nouvelle instance d’Email
sera automatiquement créée.
Ok, mais… ça ne fonctionne toujours pas !!
Non en effet. Si vous tester de soumettre le formulaire ça ne fonctionne pas. Les champs de saisie dans la vue Razor sont prévus pour fonctionner avec des primitives et savent mal, ou pas du tout, gérer les objets.
C’est la que les ModelBinder
entrent en jeu.
Le ModelBinder
va permettre d’indiquer au framework comment il devra opérer le mapping entre la valeur saisie et le type complexe qui va la recevoir. un ModelBinder
est une classe qui implémente IModelBinder:
internal class EmailModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
ArgumentNullException.ThrowIfNull(bindingContext);
string modelName = bindingContext.ModelName;
ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
return Task.CompletedTask;
bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);
Email email = new(valueProviderResult.FirstValue ?? string.Empty);
bindingContext.Result = ModelBindingResult.Success(email);
return Task.CompletedTask;
}
}
Maintenant nous allons appliquer ce binder à notre classe Email en la décorant comme ceci:
[ModelBinder(BinderType = typeof(EmailModelBinder))]
public partial class Email : IValidatableObject
...
Et voila !!
Vous avez maintenant un système qui fonctionne avec de vrais objets autonomes et le tout s’intègre bien dans les mécaniques du framework.
En dehors du fait que cette approche est bien plus propre, elle a le mérite d’être hyper facilement testable. Vous pouvez tester en une fraction de seconde toutes les combinaisons les plus farfelues pour mettre à l’épreuve le validateur d’email sans avoir recours à aucune dépendance. Un vrai test unitaire.