Loupe

Gérer les paramètres de query string sans valeur en ASP.NET Core

Anatomie d'une query string

Les query strings sont généralement constituées d'une séquence de paires clé-valeur, comme ?foo=hello&bar=world…. Cependant, si on regarde la RFC 3986, on voit que les query strings sont spécifiées de façon assez vague. Il est mentionné que

query components are often used to carry identifying information in the form of "key=value" pairs

(en français : les composants de la query sont souvent utilisés pour porter des informations d'identification sous la forme de paires "clé=valeur")

Mais c'est juste une observation, pas une règle (les RFC utilisent habituellement un langage très spécifique pour les règles, avec des mots comme MUST, SHOULD, etc.). Donc en gros, une query string peut contenir à peu près n'importe quoi, ce n'est pas du tout standardisé. L'utilisation de paires clé-valeur séparées par & est juste une convention, pas une obligation.

Et il se trouve qu'il n'est pas rare de voir des URLs avec des query strings comme ceci : ?foo, c'est-à-dire une clé sans valeur. La façon dont ça doit être interprété dépend de l'implémentation, mais dans la plupart des cas, ça veut probablement dire la même chose que ?foo=true : la simple présence du paramètre est interprétée comme une valeur true implicite.

Les query strings en ASP.NET Core

Malheureusement, en ASP.NET Core MVC, cette forme de query string n'est pas supportée, à part pour des paramètres de type string. Si vous avez une action de contrôleur comme celle-ci :

[HttpGet("search")]
public IActionResult Search(
    [FromQuery] string term,
    [FromQuery] bool ignoreCase)
{
    …
}

Le model binder par défaut attend pour le paramètre ignoreCase une valeur explicite true ou false, par exemple ignoreCase=true. Si on omet la valeur, elle sera considérée comme vide, et le binding du modèle échouera :

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "|53613c25-4767e032425dfb92.",
  "errors": {
    "ignoreCase": [
      "The value '' is invalid."
    ]
  }
}

Ce n'est pas un très gros problème, mais c'est agaçant… Voyons donc comment y remédier !

Créer son propre model binder

Par défaut, le model binding d'un paramètre booléen est fait par la classe SimpleTypeModelBinder, qui est utilisée pour la plupart des types primitifs. Ce model binder utilise le TypeConverter du type cible pour convertir une valeur de type string vers le type du paramètre. Dans ce cas, le convertisseur est un BooleanConverter, qui ne reconnait pas une valeur vide…

Il faut donc créer notre propre model binder, qui va interpréter la présence d'une clé sans valeur comme un true implicite :

class QueryBooleanModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var result = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (result == ValueProviderResult.None)
        {
            // Le paramètre est absent, interpréter comme false
            bindingContext.Result = ModelBindingResult.Success(false);
        }
        else
        {
            bindingContext.ModelState.SetModelValue(bindingContext.ModelName, result);
            var rawValue = result.FirstValue;
            if (string.IsNullOrEmpty(rawValue))
            {
                // La valeur est absente, interpréter comme true
                bindingContext.Result = ModelBindingResult.Success(true);
            }
            else if (bool.TryParse(rawValue, out var boolValue))
            {
                // La valeur est un booléen valide, utiliser cette valeur
                bindingContext.Result = ModelBindingResult.Success(boolValue);
            }
            else
            {
                // La valeur est autre chose, échec
                bindingContext.ModelState.TryAddModelError(
                    bindingContext.ModelName,
                    "Value must be false, true, or empty.");
            }
        }

        return Task.CompletedTask;
    }
}

Pour pouvoir utiliser ce model binder, il nous faut aussi un ModelBinderProvider :

class QueryBooleanModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(bool) &&
            context.BindingInfo.BindingSource != null &&
            context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Query))
        {
            return new QueryBooleanModelBinder();
        }

        return null;
    }
}

Celui-ci va renvoyer notre model binder si le type du paramètre est bool et si la source du binding est la query string. Il suffit maintenant d'ajouter notre provider à la liste des providers d'ASP.NET MVC :

// Dans Startup.ConfigureServices
services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(
        0, new QueryBooleanModelBinderProvider());
});

Note : Ce code est pour un projet ASP.NET Core 3 Web API.

  • Si votre projet utilise aussi des vues ou des pages, remplacez AddControllers par AddControllersWithViews ou AddRazorPages, selon les cas.
  • Si vous utilisez ASP.NET Core 2, remplacez AddControllers par AddMvc.

Remarquez qu'on doit insérer notre provider au début de la liste. Si on l'ajoute à la fin, un autre provider prendra la main avant, et le notre ne sera même pas appelé.

Et c'est tout : on peut maintenant appeler notre API avec une query string comme ?term=foo&ignoreCase, sans spécifier explicitement true comme valeur de ignoreCase.

Une amélioration possible de notre model binder serait d'accepter également 0 ou 1 comme des valeurs valides pour les paramètres booléens. Je laisse ça comme exercice pour le lecteur !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus