Condividi tramite


Associazione di parametri in API Web ASP.NET

È consigliabile usare ASP.NET Core web API. Offre i vantaggi seguenti rispetto all'API Web ASP.NET 4.x:

  • ASP.NET Core è un framework open source multipiattaforma per la creazione di app Web moderne basate sul cloud in Windows, macOS e Linux.
  • I controller MVC di base ASP.NET e i controller API Web sono unificati.
  • Progettazione finalizzata alla testabilità.
  • Possibilità di sviluppo ed esecuzione in Windows, macOS e Linux.
  • Open source e incentrato sulla community.
  • Integrazione di moderni framework lato client e flussi di lavoro di sviluppo.
  • Un sistema di configurazione basato sull'ambiente, pronto per il cloud.
  • Iniezione di dipendenza incorporata.
  • Una pipeline di richiesta HTTP leggera, a prestazioni elevate e modulare.
  • Possibilità di ospitare in Kestrel, IIS, HTTP.sys, Nginx, Apache e Docker.
  • Controllo delle versioni side-by-side.
  • Gli strumenti che semplificano lo sviluppo del web moderno.

Questo articolo descrive come l'API Web associa i parametri e come personalizzare il processo di associazione. Quando l'API Web chiama un metodo in un controller, deve impostare i valori per i parametri, un processo denominato binding.

Per impostazione predefinita, l'API Web usa le regole seguenti per associare i parametri:

  • Se il parametro è un tipo "semplice", l'API Web tenta di ottenere il valore dall'URI. I tipi semplici includono i tipi primitivi .NET (int, bool, double e così via), più TimeSpan, DateTime, Guid, decimal e string, oltre a qualsiasi tipo con un convertitore di tipi che può eseguire la conversione da una stringa. Altre informazioni sui convertitori di tipi più avanti.
  • Per i tipi complessi, l'API Web tenta di leggere il valore dal corpo del messaggio usando un formattatore di tipo multimediale.

Di seguito è riportato, ad esempio, un metodo tipico del controller API Web:

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

Il parametro id è un tipo "semplice", quindi l'API Web tenta di ottenere il valore dall'URI della richiesta. Il parametro item è un tipo complesso, quindi l'API Web usa un formattatore di tipo multimediale per leggere il valore dal corpo della richiesta.

Per ottenere un valore dall'URI, l'API Web cerca i dati della route e la stringa di query URI. I dati della route vengono popolati quando il sistema di routing analizza l'URI e lo associa a una route. Per ulteriori informazioni, vedere Instradamento e selezione delle azioni.

Nel resto di questo articolo verrà illustrato come personalizzare il processo di associazione del modello. Per i tipi complessi, tuttavia, è consigliabile usare i formattatori di tipo multimediale ogni volta che è possibile. Un principio fondamentale di HTTP è che le risorse vengono inviate nel corpo del messaggio, usando la negoziazione del contenuto per specificare la rappresentazione della risorsa. I formattatori di tipo multimediale sono stati progettati esattamente a questo scopo.

Uso di [FromUri]

Per forzare l'API Web a leggere un tipo complesso dall'URI, aggiungere l'attributo [FromUri] al parametro . Nell'esempio seguente viene definito un tipo GeoPoint, insieme a un metodo del controller che ottiene il GeoPoint dall'URI.

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

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

Il client può inserire i valori di Latitudine e Longitudine nella stringa di query e l'API Web li userà per costruire un oggetto GeoPoint. Ad esempio:

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

Uso di [FromBody]

Per forzare l'API Web a leggere un tipo semplice dal corpo della richiesta, aggiungere l'attributo [FromBody] al parametro :

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

In questo esempio, l'API Web userà un formattatore di tipo multimediale per leggere il valore di name dal corpo della richiesta. Ecco una richiesta client di esempio.

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

"Alice"

Quando un parametro ha [FromBody], l'API Web usa l'intestazione Content-Type per selezionare un formattatore. In questo esempio, il tipo di contenuto è "application/json" e il corpo della richiesta è una stringa JSON non elaborata (non un oggetto JSON).

Al massimo è consentito leggere dal corpo del messaggio un solo parametro. Quindi questo non funzionerà:

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

Il motivo di questa regola è che il corpo della richiesta potrebbe essere archiviato in un flusso non memorizzato nel buffer che può essere letto una sola volta.

Convertitori di tipi

È possibile fare in modo che l'API Web tratti una classe come un tipo semplice (in modo che l'API Web tenterà di associarla dall'URI) creando un TypeConverter e fornendo una conversione di stringa.

Il codice seguente mostra una GeoPoint classe che rappresenta un punto geografico, oltre a un TypeConverter che esegue la conversione da stringhe a GeoPoint istanze. La GeoPoint classe è decorata con un attributo [TypeConverter] per specificare il convertitore di tipi. Questo esempio è stato ispirato dal post di blog di Mike Stall Come eseguire il binding di oggetti personalizzati nelle firme delle azioni in 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);
    }
}

Ora l'API Web tratterà GeoPoint come un tipo semplice, nel senso che tenterà di vincolare GeoPoint i parametri dall'URI. Non è necessario includere [FromUri] nel parametro .

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

Il client può richiamare il metodo con un URI simile al seguente:

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

Strumenti di associazione di modelli

Un'opzione più flessibile rispetto a un convertitore di tipi consiste nel creare un gestore di associazione di modelli personalizzato. Con un gestore di associazione di modelli è possibile accedere a elementi come la richiesta HTTP, la descrizione dell'azione e i valori non elaborati dei dati della route.

Per creare un gestore di associazione di modelli, implementare l'interfaccia IModelBinder . Questa interfaccia definisce un singolo metodo, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Di seguito è riportato un binder di modelli per GeoPoint.

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 binder di modelli ottiene valori di input non elaborati da un provider di valori. Questa progettazione separa due funzioni distinte:

  • Il provider di valori accetta la richiesta HTTP e popola un dizionario di coppie chiave-valore.
  • Lo strumento di associazione di modelli usa questo dizionario per popolare il modello.

Il provider di valori predefinito nell'API Web ottiene i valori dai dati della route e dalla stringa di query. Ad esempio, se l'URI è http://localhost/api/values/1?location=48,-122, il provider di valori crea le coppie chiave-valore seguenti:

  • id = "1"
  • location = "48,-122"

Si assume che il modello di route predefinito sia "api/{controller}/{id}".

Il nome del parametro da associare viene archiviato nella proprietà ModelBindingContext.ModelName . Lo strumento di associazione di modelli cerca una chiave con questo valore nel dizionario. Se il valore esiste e può essere convertito in un GeoPoint, lo strumento di associazione di modelli assegna il valore associato alla proprietà ModelBindingContext.Model.

Si noti che lo strumento di associazione di modelli non è limitato a una semplice conversione dei tipi. In questo esempio, lo strumento di associazione di modelli cerca prima di tutto in una tabella di posizioni note e, in caso di errore, usa la conversione dei tipi.

Impostazione di Model Binder

Esistono diversi modi per configurare un'associazione di modelli. Prima di tutto, è possibile aggiungere un attributo [ModelBinder] al parametro .

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

È anche possibile aggiungere un attributo [ModelBinder] al tipo. L'API Web userà lo strumento di associazione di modelli specificato per tutti i parametri di tale tipo.

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

Infine, è possibile aggiungere un provider model-binder a HttpConfiguration. Un provider model-binder è semplicemente una classe factory che crea un gestore di associazione di modelli. È possibile creare un provider derivando dalla classe ModelBinderProvider . Tuttavia, se il gestore di associazione di modelli gestisce un singolo tipo, è più semplice usare il SimpleModelBinderProvider predefinito, progettato per questo scopo. A tal fine, osservare il codice indicato di seguito.

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);

        // ...
    }
}

Con un provider di associazione di modelli, è comunque necessario aggiungere l'attributo [ModelBinder] al parametro , per indicare all'API Web che deve usare un gestore di associazione di modelli e non un formattatore di tipo multimediale. Ora non è necessario specificare il tipo di model binder nell'attributo:

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

Provider di valori

Ho menzionato che un'associazione di modelli ottiene i valori da un provider di valori. Per scrivere un provider di valori personalizzato, implementare l'interfaccia IValueProvider . Ecco un esempio che esegue il pull dei valori dai cookie nella richiesta:

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;
    }
}

È anche necessario creare una value provider factory derivando dalla classe ValueProviderFactory.

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

Aggiungere la factory del provider di valori a HttpConfiguration come indicato di seguito.

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

    // ...
}

L'API Web compone tutti i provider di valori, quindi quando un gestore di associazione di modelli chiama ValueProvider.GetValue, il gestore di associazione di modelli riceve il valore dal primo provider di valori in grado di produrlo.

In alternativa, è possibile impostare la factory del provider di valori a livello di parametro usando l'attributo ValueProvider , come indicato di seguito:

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

Ciò indica all'API Web di usare l'associazione di modelli con la factory del provider di valori specificata e non di usare altri provider di valori registrati.

HttpParameterBinding

Gli associatori di modelli sono un'istanza specifica di un meccanismo più ampio. Se si esamina l'attributo [ModelBinder] , si noterà che deriva dalla classe abstract ParameterBindingAttribute . Questa classe definisce un singolo metodo, GetBinding, che restituisce un oggetto HttpParameterBinding :

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

HttpParameterBinding è responsabile dell'associazione di un parametro a un valore. Nel caso di [ModelBinder], l'attributo restituisce un'implementazione HttpParameterBinding che usa un IModelBinder per eseguire l'associazione effettiva. È anche possibile implementare il proprio HttpParameterBinding.

Si supponga, ad esempio, di voler ottenere ETag dalle if-match intestazioni e if-none-match nella richiesta. Si inizierà definendo una classe per rappresentare gli ETag.

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

Verrà anche definita un'enumerazione per indicare se ottenere l'ETag dall'intestazione if-match o dall'intestazione if-none-match.

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Di seguito è riportato un httpParameterBinding che ottiene l'ETag dall'intestazione desiderata e lo associa a un parametro di tipo 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;
    }
}

Il metodo ExecuteBindingAsync esegue l'associazione. All'interno di questo metodo aggiungere il valore del parametro vincolato al dizionario ActionArgument in HttpActionContext.

Nota

Se il metodo ExecuteBindingAsync legge il corpo del messaggio di richiesta, eseguire l'override della proprietà WillReadBody per restituire true. Il corpo della richiesta potrebbe essere un flusso senza buffer che può essere letto una sola volta, quindi l'API Web applica una regola che al massimo un'associazione può leggere il corpo del messaggio.

Per applicare un oggetto HttpParameterBinding personalizzato, è possibile definire un attributo che deriva da ParameterBindingAttribute. Per ETagParameterBinding, verranno definiti due attributi, uno per le intestazioni if-match e uno per le intestazioni if-none-match. Entrambi derivano da una classe base astratta.

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)
    {
    }
}

Ecco un metodo controller che usa l'attributo [IfNoneMatch] .

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

Oltre a ParameterBindingAttribute, è disponibile un altro hook per l'aggiunta di un httpParameterBinding personalizzato. Nell'oggetto HttpConfiguration, la proprietà ParameterBindingRules è una collezione di funzioni anonime di tipo (HttpParameterDescriptor ->HttpParameterBinding). Ad esempio, è possibile aggiungere una regola per cui qualsiasi parametro ETag su un metodo GET utilizzi ETagParameterBinding con 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 funzione deve restituire null per i parametri in cui l'associazione non è applicabile.

IActionValueBinder

L'intero processo di associazione di parametri è controllato da un servizio collegabile, IActionValueBinder. L'implementazione predefinita di IActionValueBinder esegue le operazioni seguenti:

  1. Cercare un ParameterBindingAttribute nel parametro . Sono inclusi [FromBody], [FromUri]e [ModelBinder], o attributi personalizzati.

  2. In caso contrario, cercare HttpConfiguration.ParameterBindingRules per una funzione che restituisce un valore HttpParameterBinding diverso da null.

  3. In caso contrario, usare le regole predefinite descritte in precedenza.

    • Se il tipo di parametro è "simple" o ha un convertitore di tipi, eseguire l'associazione dall'URI. Equivale a inserire l'attributo [FromUri] nel parametro .
    • In caso contrario, provare a leggere il parametro dal corpo del messaggio. Equivale a inserire [FromBody] nel parametro .

Se si vuole, è possibile sostituire l'intero servizio IActionValueBinder con un'implementazione personalizzata.

Risorse aggiuntive

Esempio di associazione di parametri personalizzata

Mike Stall ha scritto una buona serie di post di blog sull'associazione dei parametri dell'API Web: