Partager via


Liaison de paramètres dans API Web ASP.NET

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

  • ASP.NET Core est une infrastructure open source multiplateforme 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 explique comment l’API web lie des paramètres et comment vous pouvez personnaliser le processus de liaison. Lorsque 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 des paramètres :

  • Si le paramètre est un type « simple », l’API web tente d’obtenir la valeur 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 n’importe quel type avec un convertisseur de type qui peut effectuer une conversion à partir d’une chaîne. (En savoir plus sur les convertisseurs de types plus tard.)
  • Pour les types complexes, l’API Web tente de lire la valeur à partir du corps du message à l’aide d’un formateur de type média.

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

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

Le paramètre ID est un type « simple », donc l’API web tente d’obtenir la valeur de l’URI de requête. Le paramètre d’élément est un type complexe. L’API Web utilise donc un formateur de type mé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 d’itinéraire et la chaîne de requête d’URI. Les données de routage sont remplies lorsque le système de routage analyse l’URI et le correspond à un itinéraire. Pour plus d’informations, consultez Routage et sélection d’action.

Dans le reste de cet article, je vais montrer comment vous pouvez personnaliser le processus de liaison de modèle. Toutefois, pour les types complexes, envisagez d’utiliser des formateurs de type multimédia dans la mesure du 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 mé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 l’URI GeoPoint .

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 utiliseront 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 mé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 plus 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 tente 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, ainsi qu’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 types. (Cet exemple a été inspiré par le billet de blog de Mike StallGuide pratique pour 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);
    }
}

Désormais, l’API web traite GeoPoint comme un type simple, ce qui signifie qu’elle essaie de lier GeoPoint des 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èles, 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 GeoPoint les 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èles 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 d’itinéraire 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 un GeoPoint, le classeur de modèles affecte la valeur liée à la propriété ModelBindingContext.Model .

Notez que le classeur de modèles n’est pas limité à une conversion de type simple. Dans cet exemple, le classeur de modèles recherche d’abord dans une table d’emplacements connus et, en cas d’échec, 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èles 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èles. 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èles dans l’attribut :

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

Fournisseurs de valeurs

J’ai mentionné qu’un classeur de modèles 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 les valeurs des cookies dans la requête :

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 valeurs 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èles appelle ValueProvider.GetValue, le classeur de modèles reçoit la valeur du premier fournisseur de valeurs capable de le produire.

Vous pouvez également définir la fabrique du fournisseur de valeurs au niveau du paramètre à 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 d’autres fournisseurs de valeurs inscrits.

HttpParameterBinding

Les classeurs de modèles sont une 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);
}

HttpParameterBinding est responsable de la liaison d’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 souhaitez obtenir des ETags à partir if-match et if-none-match des en-têtes dans la requête. 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 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 de paramètre liée au dictionnaire ActionArgument dans HttpActionContext.

Remarque

Si votre méthode ExecuteBindingAsync lit le corps du message de requête, 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, de sorte que l’API Web applique une règle qui, au plus 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, un pour if-match les en-têtes et un 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 pour 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 utilisée par n’importe quel paramètre ETag sur une méthode ETagParameterBinding GET 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 où la liaison n’est pas applicable.

IActionValueBinder

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

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

  2. Sinon, recherchez dans HttpConfiguration.ParameterBindingRules une fonction qui retourne une valeur 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 équivaut à placer l’attribut [FromUri] sur le paramètre.
    • Sinon, essayez de lire le paramètre à partir du corps du message. Cela équivaut à 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 complé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 :