Partager via


Liaison de paramètre dans API Web ASP.NET

Envisagez d’utiliser ASP.NET Core API web. Il présente les avantages suivants par rapport à ASP.NET API Web 4.x :

  • ASP.NET Core est une infrastructure multiplateforme open source permettant de créer des applications web modernes basées sur le cloud sur Windows, macOS et Linux.
  • Les contrôleurs MVC ASP.NET Core et les contrôleurs d’API web sont unifiés.
  • Architecturé pour la testabilité.
  • Capacité à développer et à exécuter sur Windows, macOS et Linux.
  • Open source et centré sur la communauté.
  • Intégration de frameworks modernes côté client et de workflows de développement.
  • Un système de configuration prêt pour le cloud et basé sur les environnements.
  • Injection de dépendances intégrée.
  • Un pipeline des requêtes HTTP léger, à hautes performances et modulaire.
  • Possibilité d’héberger sur Kestrel, IIS, HTTP.sys, Nginx, Apache et Docker.
  • Contrôle de version côte à côte.
  • Outils qui simplifient le développement web moderne.

Cet article décrit comment l’API web lie les paramètres et comment vous pouvez personnaliser le processus de liaison. Quand l’API web appelle une méthode sur un contrôleur, elle doit définir des valeurs pour les paramètres, un processus appelé liaison.

Par défaut, l’API web utilise les règles suivantes pour lier les paramètres :

  • Si le paramètre est un type « simple », l’API web tente d’obtenir la valeur à partir de l’URI. Les types simples incluent les types primitifs .NET (int, bool, double, etc.), ainsi que TimeSpan, DateTime, Guid, decimal et string, ainsi que tout type avec un convertisseur de type qui peut convertir à partir d’une chaîne. (Plus d’informations sur les convertisseurs de type ultérieurement.)
  • Pour les types complexes, l’API web tente de lire la valeur du corps du message à l’aide d’un formateur de type multimédia.

Par exemple, voici une méthode de contrôleur d’API web classique :

HttpResponseMessage Put(int id, Product item) { ... }

Le paramètre id étant un type « simple », l’API web tente d’obtenir la valeur à partir de l’URI de requête. Le paramètre item étant un type complexe, l’API web utilise un formateur de type multimédia pour lire la valeur du corps de la requête.

Pour obtenir une valeur à partir de l’URI, l’API web recherche les données de routage et la chaîne de requête URI. Les données de routage sont remplies lorsque le système de routage analyse l’URI et le fait correspondre à une route. Pour plus d’informations, consultez Routage et sélection d’actions.

Dans le reste de cet article, je vais montrer comment personnaliser le processus de liaison de modèle. Toutefois, pour les types complexes, envisagez d’utiliser des formateurs de type multimédia chaque fois que cela est possible. Un principe clé de HTTP est que les ressources sont envoyées dans le corps du message, à l’aide de la négociation de contenu pour spécifier la représentation de la ressource. Les formateurs de type multimédia ont été conçus à cet effet.

Utilisation de [FromUri]

Pour forcer l’API web à lire un type complexe à partir de l’URI, ajoutez l’attribut [FromUri] au paramètre. L’exemple suivant définit un GeoPoint type, ainsi qu’une méthode de contrôleur qui obtient le GeoPoint à partir de l’URI.

public class GeoPoint
{
    public double Latitude { get; set; } 
    public double Longitude { get; set; }
}

public ValuesController : ApiController
{
    public HttpResponseMessage Get([FromUri] GeoPoint location) { ... }
}

Le client peut placer les valeurs Latitude et Longitude dans la chaîne de requête et l’API Web les utilise pour construire un GeoPoint. Par exemple :

http://localhost/api/values/?Latitude=47.678558&Longitude=-122.130989

Utilisation de [FromBody]

Pour forcer l’API web à lire un type simple à partir du corps de la requête, ajoutez l’attribut [FromBody] au paramètre :

public HttpResponseMessage Post([FromBody] string name) { ... }

Dans cet exemple, l’API web utilise un formateur de type multimédia pour lire la valeur du nom à partir du corps de la requête. Voici un exemple de demande cliente.

POST http://localhost:5076/api/values HTTP/1.1
User-Agent: Fiddler
Host: localhost:5076
Content-Type: application/json
Content-Length: 7

"Alice"

Lorsqu’un paramètre a [FromBody], l’API web utilise l’en-tête Content-Type pour sélectionner un formateur. Dans cet exemple, le type de contenu est « application/json » et le corps de la requête est une chaîne JSON brute (et non un objet JSON).

Au maximum, un paramètre est autorisé à lire à partir du corps du message. Par conséquent, cela ne fonctionnera pas :

// Caution: Will not work!    
public HttpResponseMessage Post([FromBody] int id, [FromBody] string name) { ... }

La raison de cette règle est que le corps de la requête peut être stocké dans un flux non mis en mémoire tampon qui ne peut être lu qu’une seule fois.

Convertisseurs de type

Vous pouvez faire en sorte que l’API Web traite une classe comme un type simple (afin que l’API Web essaie de la lier à partir de l’URI) en créant un TypeConverter et en fournissant une conversion de chaîne.

Le code suivant montre une GeoPoint classe qui représente un point géographique, plus un TypeConverter qui convertit des chaînes en GeoPoint instances. La GeoPoint classe est décorée avec un attribut [TypeConverter] pour spécifier le convertisseur de type. (Cet exemple a été inspiré par le billet de blog de Mike Stall Comment lier des objets personnalisés dans des signatures d’action dans MVC/WebAPI.)

[TypeConverter(typeof(GeoPointConverter))]
public class GeoPoint
{
    public double Latitude { get; set; } 
    public double Longitude { get; set; }

    public static bool TryParse(string s, out GeoPoint result)
    {
        result = null;

        var parts = s.Split(',');
        if (parts.Length != 2)
        {
            return false;
        }

        double latitude, longitude;
        if (double.TryParse(parts[0], out latitude) &&
            double.TryParse(parts[1], out longitude))
        {
            result = new GeoPoint() { Longitude = longitude, Latitude = latitude };
            return true;
        }
        return false;
    }
}

class GeoPointConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        if (sourceType == typeof(string))
        {
            return true;
        }
        return base.CanConvertFrom(context, sourceType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, 
        CultureInfo culture, object value)
    {
        if (value is string)
        {
            GeoPoint point;
            if (GeoPoint.TryParse((string)value, out point))
            {
                return point;
            }
        }
        return base.ConvertFrom(context, culture, value);
    }
}

À présent, l’API web traitera GeoPoint comme un type simple, ce qui signifie qu’elle essaiera de lier GeoPoint les paramètres à partir de l’URI. Vous n’avez pas besoin d’inclure [FromUri] sur le paramètre.

public HttpResponseMessage Get(GeoPoint location) { ... }

Le client peut appeler la méthode avec un URI comme suit :

http://localhost/api/values/?location=47.678558,-122.130989

Classeurs de modèles

Une option plus flexible qu’un convertisseur de type consiste à créer un classeur de modèles personnalisé. Avec un classeur de modèle, vous avez accès à des éléments tels que la requête HTTP, la description de l’action et les valeurs brutes des données de routage.

Pour créer un classeur de modèles, implémentez l’interface IModelBinder . Cette interface définit une méthode unique, BindModel :

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Voici un classeur de modèles pour les GeoPoint objets.

public class GeoPointModelBinder : IModelBinder
{
    // List of known locations.
    private static ConcurrentDictionary<string, GeoPoint> _locations
        = new ConcurrentDictionary<string, GeoPoint>(StringComparer.OrdinalIgnoreCase);

    static GeoPointModelBinder()
    {
        _locations["redmond"] = new GeoPoint() { Latitude = 47.67856, Longitude = -122.131 };
        _locations["paris"] = new GeoPoint() { Latitude = 48.856930, Longitude = 2.3412 };
        _locations["tokyo"] = new GeoPoint() { Latitude = 35.683208, Longitude = 139.80894 };
    }

    public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(GeoPoint))
        {
            return false;
        }

        ValueProviderResult val = bindingContext.ValueProvider.GetValue(
            bindingContext.ModelName);
        if (val == null)
        {
            return false;
        }

        string key = val.RawValue as string;
        if (key == null)
        {
            bindingContext.ModelState.AddModelError(
                bindingContext.ModelName, "Wrong value type");
            return false;
        }

        GeoPoint result;
        if (_locations.TryGetValue(key, out result) || GeoPoint.TryParse(key, out result))
        {
            bindingContext.Model = result;
            return true;
        }

        bindingContext.ModelState.AddModelError(
            bindingContext.ModelName, "Cannot convert value to GeoPoint");
        return false;
    }
}

Un classeur de modèle obtient des valeurs d’entrée brutes d’un fournisseur de valeurs. Cette conception sépare deux fonctions distinctes :

  • Le fournisseur de valeurs accepte la requête HTTP et remplit un dictionnaire de paires clé-valeur.
  • Le classeur de modèles utilise ce dictionnaire pour remplir le modèle.

Le fournisseur de valeurs par défaut dans l’API web obtient les valeurs des données de routage et de la chaîne de requête. Par exemple, si l’URI est http://localhost/api/values/1?location=48,-122, le fournisseur de valeurs crée les paires clé-valeur suivantes :

  • id = « 1 »
  • location = « 48,-122 »

(Je suppose que le modèle d’itinéraire par défaut, qui est « api/{controller}/{id} ».

Le nom du paramètre à lier est stocké dans la propriété ModelBindingContext.ModelName . Le classeur de modèles recherche une clé avec cette valeur dans le dictionnaire. Si la valeur existe et peut être convertie en , GeoPointle classeur de modèle affecte la valeur liée à la propriété ModelBindingContext.Model .

Notez que le classeur de modèle n’est pas limité à une simple conversion de type. Dans cet exemple, le classeur de modèles recherche d’abord dans une table d’emplacements connus et, si cela échoue, il utilise la conversion de type.

Définition du classeur de modèles

Il existe plusieurs façons de définir un classeur de modèles. Tout d’abord, vous pouvez ajouter un attribut [ModelBinder] au paramètre.

public HttpResponseMessage Get([ModelBinder(typeof(GeoPointModelBinder))] GeoPoint location)

Vous pouvez également ajouter un attribut [ModelBinder] au type. L’API web utilise le classeur de modèle spécifié pour tous les paramètres de ce type.

[ModelBinder(typeof(GeoPointModelBinder))]
public class GeoPoint
{
    // ....
}

Enfin, vous pouvez ajouter un fournisseur de classeur de modèles à HttpConfiguration. Un fournisseur de classeur de modèles est simplement une classe de fabrique qui crée un classeur de modèle. Vous pouvez créer un fournisseur en dérivant de la classe ModelBinderProvider . Toutefois, si votre classeur de modèles gère un type unique, il est plus facile d’utiliser le SimpleModelBinderProvider intégré, qui est conçu à cet effet. Le code suivant montre comment procéder.

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        var provider = new SimpleModelBinderProvider(
            typeof(GeoPoint), new GeoPointModelBinder());
        config.Services.Insert(typeof(ModelBinderProvider), 0, provider);

        // ...
    }
}

Avec un fournisseur de liaison de modèle, vous devez toujours ajouter l’attribut [ModelBinder] au paramètre pour indiquer à l’API web qu’elle doit utiliser un classeur de modèles et non un formateur de type média. Mais maintenant, vous n’avez pas besoin de spécifier le type de classeur de modèle dans l’attribut :

public HttpResponseMessage Get([ModelBinder] GeoPoint location) { ... }

Fournisseurs de valeurs

J’ai mentionné qu’un classeur de modèle obtient des valeurs d’un fournisseur de valeurs. Pour écrire un fournisseur de valeurs personnalisé, implémentez l’interface IValueProvider . Voici un exemple qui extrait des valeurs des cookies dans la demande :

public class CookieValueProvider : IValueProvider
{
    private Dictionary<string, string> _values;

    public CookieValueProvider(HttpActionContext actionContext)
    {
        if (actionContext == null)
        {
            throw new ArgumentNullException("actionContext");
        }

        _values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var cookie in actionContext.Request.Headers.GetCookies())
        {
            foreach (CookieState state in cookie.Cookies)
            {
                _values[state.Name] = state.Value;
            }
        }
    }

    public bool ContainsPrefix(string prefix)
    {
        return _values.Keys.Contains(prefix);
    }

    public ValueProviderResult GetValue(string key)
    {
        string value;
        if (_values.TryGetValue(key, out value))
        {
            return new ValueProviderResult(value, value, CultureInfo.InvariantCulture);
        }
        return null;
    }
}

Vous devez également créer une fabrique de fournisseur de valeur en dérivant de la classe ValueProviderFactory .

public class CookieValueProviderFactory : ValueProviderFactory
{
    public override IValueProvider GetValueProvider(HttpActionContext actionContext)
    {
        return new CookieValueProvider(actionContext);
    }
}

Ajoutez la fabrique de fournisseur de valeurs à HttpConfiguration comme suit.

public static void Register(HttpConfiguration config)
{
    config.Services.Add(typeof(ValueProviderFactory), new CookieValueProviderFactory());

    // ...
}

L’API web compose tous les fournisseurs de valeurs. Par conséquent, lorsqu’un classeur de modèle appelle ValueProvider.GetValue, le classeur de modèles reçoit la valeur du premier fournisseur de valeurs capable de la produire.

Vous pouvez également définir la fabrique du fournisseur de valeurs au niveau des paramètres à l’aide de l’attribut ValueProvider , comme suit :

public HttpResponseMessage Get(
    [ValueProvider(typeof(CookieValueProviderFactory))] GeoPoint location)

Cela indique à l’API web d’utiliser la liaison de modèle avec la fabrique de fournisseur de valeurs spécifiée et de ne pas utiliser les autres fournisseurs de valeurs inscrits.

HttpParameterBinding

Les classeurs de modèles sont un instance spécifique d’un mécanisme plus général. Si vous examinez l’attribut [ModelBinder], vous verrez qu’il dérive de la classe ParameterBindingAttribute abstraite. Cette classe définit une méthode unique, GetBinding, qui retourne un objet HttpParameterBinding :

public abstract class ParameterBindingAttribute : Attribute
{
    public abstract HttpParameterBinding GetBinding(HttpParameterDescriptor parameter);
}

Un httpParameterBinding est chargé de lier un paramètre à une valeur. Dans le cas de [ModelBinder], l’attribut retourne une implémentation HttpParameterBinding qui utilise un IModelBinder pour effectuer la liaison réelle. Vous pouvez également implémenter votre propre HttpParameterBinding.

Par exemple, supposons que vous souhaitiez obtenir des ETags à partir des if-match en-têtes et if-none-match dans la demande. Nous allons commencer par définir une classe pour représenter les ETags.

public class ETag
{
    public string Tag { get; set; }
}

Nous allons également définir une énumération pour indiquer s’il faut obtenir l’ETag à partir de l’en-tête if-match ou de l’en-tête if-none-match .

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Voici un objet HttpParameterBinding qui obtient l’ETag à partir de l’en-tête souhaité et le lie à un paramètre de type ETag :

public class ETagParameterBinding : HttpParameterBinding
{
    ETagMatch _match;

    public ETagParameterBinding(HttpParameterDescriptor parameter, ETagMatch match) 
        : base(parameter)
    {
        _match = match;
    }

    public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, 
        HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        EntityTagHeaderValue etagHeader = null;
        switch (_match)
        {
            case ETagMatch.IfNoneMatch:
                etagHeader = actionContext.Request.Headers.IfNoneMatch.FirstOrDefault();
                break;

            case ETagMatch.IfMatch:
                etagHeader = actionContext.Request.Headers.IfMatch.FirstOrDefault();
                break;
        }

        ETag etag = null;
        if (etagHeader != null)
        {
            etag = new ETag { Tag = etagHeader.Tag };
        }
        actionContext.ActionArguments[Descriptor.ParameterName] = etag;

        var tsc = new TaskCompletionSource<object>();
        tsc.SetResult(null);
        return tsc.Task;
    }
}

La méthode ExecuteBindingAsync effectue la liaison. Dans cette méthode, ajoutez la valeur du paramètre lié au dictionnaire ActionArgument dans httpActionContext.

Notes

Si votre méthode ExecuteBindingAsync lit le corps du message de demande, remplacez la propriété WillReadBody pour retourner true. Le corps de la requête peut être un flux non débogué qui ne peut être lu qu’une seule fois. L’API web applique donc une règle selon laquelle au maximum une liaison peut lire le corps du message.

Pour appliquer un HttpParameterBinding personnalisé, vous pouvez définir un attribut qui dérive de ParameterBindingAttribute. Pour ETagParameterBinding, nous allons définir deux attributs, l’un pour if-match les en-têtes et l’autre pour if-none-match les en-têtes. Les deux dérivent d’une classe de base abstraite.

public abstract class ETagMatchAttribute : ParameterBindingAttribute
{
    private ETagMatch _match;

    public ETagMatchAttribute(ETagMatch match)
    {
        _match = match;
    }

    public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter)
    {
        if (parameter.ParameterType == typeof(ETag))
        {
            return new ETagParameterBinding(parameter, _match);
        }
        return parameter.BindAsError("Wrong parameter type");
    }
}

public class IfMatchAttribute : ETagMatchAttribute
{
    public IfMatchAttribute()
        : base(ETagMatch.IfMatch)
    {
    }
}

public class IfNoneMatchAttribute : ETagMatchAttribute
{
    public IfNoneMatchAttribute()
        : base(ETagMatch.IfNoneMatch)
    {
    }
}

Voici une méthode de contrôleur qui utilise l’attribut [IfNoneMatch] .

public HttpResponseMessage Get([IfNoneMatch] ETag etag) { ... }

Outre ParameterBindingAttribute, il existe un autre hook permettant d’ajouter un HttpParameterBinding personnalisé. Sur l’objet HttpConfiguration , la propriété ParameterBindingRules est une collection de fonctions anonymes de type (HttpParameterDescriptor ->HttpParameterBinding). Par exemple, vous pouvez ajouter une règle que n’importe quel paramètre ETag sur une méthode GET utilise ETagParameterBinding avec if-none-match:

config.ParameterBindingRules.Add(p =>
{
    if (p.ParameterType == typeof(ETag) && 
        p.ActionDescriptor.SupportedHttpMethods.Contains(HttpMethod.Get))
    {
        return new ETagParameterBinding(p, ETagMatch.IfNoneMatch);
    }
    else
    {
        return null;
    }
});

La fonction doit retourner null pour les paramètres pour lesquels la liaison n’est pas applicable.

IActionValueBinder

L’ensemble du processus de liaison de paramètres est contrôlé par un service enfichable, IActionValueBinder. L’implémentation par défaut de IActionValueBinder effectue les opérations suivantes :

  1. Recherchez un ParamètreBindingAttribute sur le paramètre . Cela inclut [FromBody], [FromUri] et [ModelBinder] ou les attributs personnalisés.

  2. Sinon, recherchez dans HttpConfiguration.ParameterBindingRules une fonction qui retourne un httpParameterBinding non null.

  3. Sinon, utilisez les règles par défaut que j’ai décrites précédemment.

    • Si le type de paramètre est « simple » ou a un convertisseur de type, liez à partir de l’URI. Cela revient à placer l’attribut [FromUri] sur le paramètre .
    • Sinon, essayez de lire le paramètre à partir du corps du message. Cela revient à placer [FromBody] sur le paramètre .

Si vous le souhaitez, vous pouvez remplacer l’ensemble du service IActionValueBinder par une implémentation personnalisée.

Ressources supplémentaires

Exemple de liaison de paramètre personnalisé

Mike Stall a écrit une bonne série de billets de blog sur la liaison de paramètres d’API web :