Enlace de parámetros en API Web de ASP.NET

Considere la posibilidad de usar la API Web de ASP.NET Core. Tiene las siguientes ventajas sobre API Web de ASP.NET 4.x:

  • ASP.NET Core es un marco multiplataforma de código abierto que tiene como finalidad compilar modernas aplicaciones web basadas en la nube en Windows, macOS y Linux.
  • Los controladores ASP.NET Core MVC y los controladores de API Web están unificados.
  • Diseñado para la capacidad de prueba.
  • Capacidad para desarrollarse y ejecutarse en Windows, macOS y Linux.
  • De código abierto y centrado en la comunidad.
  • Integración de marcos del lado cliente modernos y flujos de trabajo de desarrollo.
  • Un sistema de configuración basado en el entorno y preparado para la nube.
  • Inserción de dependencias integrada.
  • Una canalización de solicitudes HTTP ligera, modular y de alto rendimiento.
  • Capacidad de hospedar en Kestrel, IIS, HTTP.sys, Nginx, Apache y Docker.
  • Control de versiones en paralelo.
  • Herramientas que simplifican el desarrollo web moderno.

En este artículo, se describe cómo API Web enlaza parámetros y cómo puede personalizar el proceso de enlace. Cuando la API web llama a un método en un controlador, debe establecer valores para los parámetros, un proceso denominado enlace.

De forma predeterminada, API Web usa las siguientes reglas para enlazar parámetros:

  • Si el parámetro es un tipo "simple", la API Web intenta obtener el valor del identificador URI. Los tipos simples incluyen los tipos primitivos de .NET (int, bool, double, etc.), además de TimeSpan, DateTime, Guid, decimal y string, además de cualquier tipo con un convertidor de tipos que pueda convertir de una cadena. (Más información sobre los convertidores de tipos más adelante).
  • En el caso de tipos complejos, la API Web intenta leer el valor del cuerpo del mensaje mediante un formateador de tipo multimedia.

Por ejemplo, este es un método típico del controlador de API Web:

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

El parámetro id es un tipo "simple", por lo que la API Web intenta obtener el valor del identificador URI de solicitud. El parámetro item es un tipo complejo, por lo que API Web usa un formateador de tipo multimedia para leer el valor del cuerpo de la solicitud.

Para obtener un valor del identificador URI, la API web busca los datos de ruta y la cadena de consulta del identificador URI. Los datos de ruta se rellenan cuando el sistema de enrutamiento analiza el URI y lo hace coincidir con una ruta. Para más información, consulte Enrutamiento y selección de acciones.

En el resto de este artículo, mostraré cómo puede personalizar el proceso de enlace de modelos. Sin embargo, en el caso de los tipos complejos, considere la posibilidad de usar formateadores de tipo multimedia siempre que sea posible. Un principio clave de HTTP es que los recursos se envían en el cuerpo del mensaje, mediante la negociación de contenido para especificar la representación del recurso. Los formateadores de tipo multimedia se diseñaron exactamente para este propósito.

Uso de [FromUri]

Para forzar que la API web lea un tipo complejo desde el URI, agregue el atributo [FromUri] al parámetro. En el ejemplo siguiente, se define un tipo GeoPoint, junto con un método de controlador que obtiene el GeoPoint del identificador URI.

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

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

El cliente puede colocar los valores latitud y longitud en la cadena de consulta y la API Web los usará para construir un GeoPoint. Por ejemplo:

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

Uso de [FromBody]

Para forzar que API Web lea un tipo simple desde el cuerpo de la solicitud, agregue el atributo [FromBody] al parámetro:

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

En este ejemplo, la API Web usará un formateador de tipo multimedia para leer el valor de name del cuerpo de la solicitud. Este es un ejemplo de solicitud de cliente.

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

"Alice"

Cuando un parámetro tiene [FromBody], la API Web usa el encabezado Content-Type para seleccionar un formateador. En este ejemplo, el tipo de contenido es "application/json" y el cuerpo de la solicitud es una cadena JSON sin formato (no un objeto JSON).

Como máximo, se permite leer un parámetro desde el cuerpo del mensaje. Por tanto, esto no funcionará:

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

El motivo de esta regla es que el cuerpo de la solicitud se puede almacenar en una secuencia no almacenada en búfer que solo se puede leer una vez.

Convertidores de tipos

Puede hacer que la API Web trate una clase como un tipo simple (para que la API Web intente enlazarla desde el identificador URI) creando un TypeConverter y proporcionando una conversión de cadena.

El código siguiente muestra una clase GeoPoint que representa un punto geográfico, además de un TypeConverter que convierte de cadenas a instancias GeoPoint. La clase GeoPoint está decorada con un atributo [TypeConverter] para especificar el convertidor de tipos. (Este ejemplo se inspiró en la entrada de blog de Mike Stall Enlace a objetos personalizados en firmas de acción en 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);
    }
}

Ahora la API Web tratará GeoPoint como un tipo simple, lo que significa que intentará enlazar parámetros GeoPoint desde el identificador URI. No es necesario incluir [FromUri] en el parámetro.

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

El cliente puede invocar el método con un identificador URI similar al siguiente:

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

Enlazadores modelo

Una opción más flexible que un convertidor de tipos es crear un enlazador de modelos personalizado. Con un enlazador de modelos, tiene acceso a elementos como la solicitud HTTP, la descripción de la acción y los valores sin procesar de los datos de ruta.

Para crear un enlazador de modelos, implemente la interfaz IModelBinder. Esta interfaz define un único método, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Este es un enlazador de modelos para objetos 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 enlazador de modelos obtiene valores de entrada sin procesar de un proveedor de valores. Este diseño separa dos funciones distintas:

  • El proveedor de valores toma la solicitud HTTP y rellena un diccionario de pares clave-valor.
  • El enlazador de modelos usa este diccionario para rellenar el modelo.

El proveedor de valores predeterminado de API Web obtiene valores de los datos de ruta y la cadena de consulta. Por ejemplo, si el identificador URI es http://localhost/api/values/1?location=48,-122, el proveedor de valores crea los siguientes pares clave-valor:

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

(Estoy suponiendo que la plantilla de ruta predeterminada es "api/{controller}/{id}").

El nombre del parámetro que se va a enlazar se almacena en la propiedad ModelBindingContext.ModelName. El enlazador de modelos busca una clave con este valor en el diccionario. Si el valor existe y se puede convertir en GeoPoint, el enlazador de modelos asigna el valor enlazado a la propiedad ModelBindingContext.Model.

Observe que el enlazador de modelos no está limitado a una conversión de tipos simple. En este ejemplo, el enlazador de modelos busca primero en una tabla de ubicaciones conocidas y, si se produce un error, usa la conversión de tipos.

Establecimiento del enlazador de modelos

Hay varias maneras de establecer un enlazador de modelos. En primer lugar, puede agregar un atributo [ModelBinder] al parámetro.

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

También puede agregar un atributo [ModelBinder] al tipo. La API Web usará el enlazador de modelos especificado para todos los parámetros de ese tipo.

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

Por último, puede agregar un proveedor de enlazador de modelos a HttpConfiguration. Un proveedor de enlazador de modelos es simplemente una clase de fábrica que crea un enlazador de modelos. Puede crear un proveedor derivando de la clase ModelBinderProvider. Sin embargo, si el enlazador de modelos controla un solo tipo, es más fácil usar SimpleModelBinderProvider integrado, que está diseñado para este propósito. El código siguiente muestra cómo hacerlo.

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 proveedor de enlace de modelos, debe agregar el atributo [ModelBinder] al parámetro para indicar a la API Web que debe usar un enlazador de modelos y no un formateador de tipo multimedia. Sin embargo, ahora no es necesario especificar el tipo de enlazador de modelos en el atributo:

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

Proveedores de valores

He mencionado que un enlazador de modelos obtiene valores de un proveedor de valores. Para escribir un proveedor de valores personalizado, implemente la interfaz IValueProvider. Este es un ejemplo que extrae valores de las cookies en la solicitud:

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

También debe crear un generador de proveedores de valores derivando de la clase ValueProviderFactory.

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

Agregue el generador de proveedores de valores a HttpConfiguration como se indica a continuación.

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

    // ...
}

La API Web compone todos los proveedores de valores, por lo que cuando un enlazador de modelos llama a ValueProvider.GetValue, el enlazador de modelos recibe el valor del primer proveedor de valores que puede generarlo.

Como alternativa, puede establecer el generador de proveedores de valores en el nivel de parámetro mediante el atributo ValueProvider, como se indica a continuación:

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

Esto indica a la API Web que use el enlace de modelos con el generador de proveedores de valores especificado y que no use ninguno de los otros proveedores de valores registrados.

HttpParameterBinding

Los enlazadores de modelos son una instancia específica de un mecanismo más general. Si observa el atributo [ModelBinder], verá que se deriva de la clase abstracta ParameterBindingAttribute. Esta clase define un único método, GetBinding, que devuelve un objeto HttpParameterBinding:

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

Un HttpParameterBinding es responsable de enlazar un parámetro a un valor. En el caso de [ModelBinder], el atributo devuelve una implementación de HttpParameterBinding que usa un IModelBinder para realizar el enlace real. También puede implementar su propio HttpParameterBinding.

Por ejemplo, supongamos que quiere obtener ETags de encabezados if-match y if-none-match en la solicitud. Comenzaremos definiendo una clase para representar ETags.

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

También definiremos una enumeración para indicar si se va a obtener la ETag del encabezado if-match o del encabezado if-none-match.

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Este es un HttpParameterBinding que obtiene el ETag del encabezado deseado y lo enlaza a un parámetro de 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;
    }
}

El método ExecuteBindingAsync realiza el enlace. En este método, agregue el valor del parámetro enlazado al diccionario ActionArgument en HttpActionContext.

Nota:

Si el método ExecuteBindingAsync lee el cuerpo del mensaje de solicitud, invalide la propiedad WillReadBody para devolver true. El cuerpo de la solicitud puede ser una secuencia sin búfer que solo se puede leer una vez, por lo que la API Web aplica una regla que, a lo mucho, un enlace puede leer el cuerpo del mensaje.

Para aplicar un HttpParameterBinding personalizado, puede definir un atributo que derive de ParameterBindingAttribute. Para ETagParameterBinding, definiremos dos atributos, uno para los encabezados if-match y otro para los encabezados if-none-match. Ambos derivan de una clase base abstracta.

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

Este es un método de controlador que usa el atributo [IfNoneMatch].

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

Además de ParameterBindingAttribute, hay otro enlace para agregar un HttpParameterBinding personalizado. En el objeto HttpConfiguration, la propiedad ParameterBindingRules es una colección de funciones anónimas de tipo (HttpParameterDescriptor ->HttpParameterBinding). Por ejemplo, podría agregar una regla que cualquier parámetro ETag en un método GET use 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 función debe devolver null para los parámetros en los que el enlace no es aplicable.

IActionValueBinder

Todo el proceso de enlace de parámetros se controla mediante un servicio conectable, IActionValueBinder. La implementación predeterminada de IActionValueBinder hace lo siguiente:

  1. Busque ParameterBindingAttribute en el parámetro. Esto incluye [FromBody], [FromUri] y [ModelBinder], o atributos personalizados.

  2. De lo contrario, busque en HttpConfiguration.ParameterBindingRules para una función que devuelva un HttpParameterBinding distinto de null.

  3. De lo contrario, use las reglas predeterminadas que describí anteriormente.

    • Si el tipo de parámetro es "simple" o tiene un convertidor de tipos, enlace desde el identificador URI. Esto equivale a colocar el atributo [FromUri] en el parámetro.
    • De lo contrario, intente leer el parámetro del cuerpo del mensaje. Esto equivale a colocar [FromBody] en el parámetro.

Si lo desea, podría reemplazar todo el servicio IActionValueBinder por una implementación personalizada.

Recursos adicionales

Ejemplo de enlace de parámetros personalizado

Mike Stall escribió una buena serie de entradas de blog sobre el enlace de parámetros de API Web: