Associazione di parametri in API Web ASP.NET

Prendere in considerazione l'uso dell'API Web ASP.NET Core. Offre i vantaggi seguenti rispetto all'API Web 4.x ASP.NET:

  • ASP.NET Core è un framework open source e multipiattaforma per la creazione di app Web moderne basate sul cloud in Windows, macOS e Linux.
  • I controller MVC e i controller API Web ASP.NET Core sono unificati.
  • Progettazione finalizzata alla testabilità.
  • Possibilità di sviluppo ed esecuzione in Windows, macOS e Linux.
  • Focalizzati su risorse open source e sulle community.
  • Integrazione di framework lato client moderni e flussi di lavoro di sviluppo.
  • Un sistema di configurazione basato sull'ambiente, pronto per il cloud.
  • Inserimento delle dipendenze incorporato.
  • Una pipeline di richiesta HTTP leggera, ad alte prestazioni 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 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, decimale e stringa, oltre a qualsiasi tipo con un convertitore di tipi che può convertire 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.

Ecco, 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 di route e la stringa di query URI. I dati di route vengono popolati quando il sistema di routing analizza l'URI e lo corrisponde a una route. Per altre informazioni, vedere Routing 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 chiave 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 per questo scopo.

Uso di [FromUri]

Per forzare la lettura di un tipo complesso dall'URI, aggiungere l'attributo [FromUri] al parametro. Nell'esempio seguente viene definito un tipo, insieme a un GeoPoint metodo controller che ottiene 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) { ... }
}

Il client può inserire i valori Latitudine e Longitudine nella stringa di query e nell'API Web li userà per costruire un GeoPointoggetto . 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 del nome 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 un parametro è consentito leggere dal corpo del messaggio. 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 buffer che può essere letto una sola volta.

Convertitori di tipi

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

Il codice seguente mostra una classe che rappresenta un GeoPoint punto geografico e un TypeConverter che converte 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 associare agli oggetti personalizzati nelle firme d'azione 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 verrà considerata GeoPoint come un tipo semplice, ovvero tenterà di associare 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

Raccoglitori di modelli

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

Per creare un binder modello, implementare l'interfaccia IModelBinder . Questa interfaccia definisce un singolo metodo, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Ecco un bindinger modello per GeoPoint gli oggetti.

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 bindinger modello 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.
  • Il binding del modello usa questo dizionario per popolare il modello.

Il provider di valori predefinito nell'API Web ottiene valori dai dati di 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 presuppone che il modello di route predefinito sia "api/{controller}/{id}".

Il nome del parametro da associare viene archiviato nella proprietà ModelBindingContext.ModelName . Il bindinger del modello cerca una chiave con questo valore nel dizionario. Se il valore esiste e può essere convertito in un GeoPointoggetto , il binding del modello assegna il valore associato alla proprietà ModelBindingContext.Model .

Si noti che il bindinger del modello non è limitato a una semplice conversione dei tipi. In questo esempio, il bindinger modello cerca prima in una tabella di posizioni note e, se ha esito negativo, usa la conversione dei tipi.

Impostazione del binding del modello

Esistono diversi modi per impostare un bindinger modello. 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à il bindinger del modello specificato per tutti i parametri di tale tipo.

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

Infine, è possibile aggiungere un provider model-binder all'oggetto HttpConfiguration. Un provider model-binder è semplicemente una classe factory che crea un binding del modello. È possibile creare un provider derivando dalla classe ModelBinderProvider . Tuttavia, se il bindinger del modello gestisce un singolo tipo, è più semplice usare 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 binding del modello e non un formattatore di tipo multimediale. Ma ora non è necessario specificare il tipo di bindinger del modello nell'attributo:

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

Provider di valori

È stato indicato che un bindinger modello ottiene 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 factory del provider di valori 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 binder modello chiama ValueProvider.GetValue, il binding del modello 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)

In questo modo l'API Web usa l'associazione di modelli con la factory del provider di valori specificata e non usa nessuno degli altri provider di valori registrati.

HttpParameterBinding

I binding di modelli sono un'istanza specifica di un meccanismo più generale. 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);
}

Un oggetto 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 ETags da if-match e if-none-match intestazioni nella richiesta. Si inizierà definendo una classe per rappresentare ETags.

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

Verrà inoltre definita un'enumerazione per indicare se ottenere L'ETag dall'intestazione o dall'intestazione if-matchif-none-match .

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Ecco 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 associato al dizionario ActionArgument nel 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 non restituito 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 e uno per if-matchif-none-match le intestazioni. 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 aggiungere un httpParameterBinding personalizzato. Nell'oggetto HttpConfiguration la proprietà ParameterBindingRules è una raccolta di funzioni anonime di tipo (HttpParameterDescriptor ->HttpParameterBinding). Ad esempio, è possibile aggiungere una regola utilizzata ETagParameterBinding da qualsiasi parametro ETag in un metodo GET 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 dei parametri è controllato da un servizio pluggable, IActionValueBinder. L'implementazione predefinita di IActionValueBinder esegue le operazioni seguenti:

  1. Cercare un parametroBindingAttribute nel parametro. Ciò include [FromBody], [FromUri]e [ModelBinder]o attributi personalizzati.

  2. In caso contrario, cercare HttpConfiguration.ParameterBindingRules per una funzione che restituisce un oggetto HttpParameterBinding non null.

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

    • Se il tipo di parametro è "semplice" o ha un convertitore di tipi, associare 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: