Associação de parâmetro no ASP.NET Web API

por Mike Wasson

Considere o uso da API Web do ASP.NET Core. Ele tem as seguintes vantagens em relação à API Web do ASP.NET 4. x:

  • O ASP.NET Core é uma estrutura de plataforma cruzada de software livre para a criação de aplicativos Web modernos baseados em nuvem no Windows, no macOS e no Linux.
  • Os controladores MVC ASP.NET Core e os controladores de API da Web são unificados.
  • Projetado para capacidade de teste.
  • Capacidade de desenvolver e executar no Windows, macOS e Linux.
  • De software livre e voltado para a comunidade.
  • Integração de estruturas modernas, do lado do cliente e fluxos de trabalho de desenvolvimento.
  • Um sistema de configuração baseado em ambiente pronto para a nuvem.
  • Injeção de dependência interna.
  • Um pipeline de solicitação HTTP leve, modular e de alto desempenho.
  • Capacidade de hospedar em Kestrel, IIS, http. sys, Nginx, Apachee Docker.
  • Controle de versão lado a lado.
  • Ferramentas que simplificam o moderno desenvolvimento para a Web.

Este artigo descreve como a API Web associa parâmetros e como você pode personalizar o processo de associação. Quando a API Web chama um método em um controlador, ela deve definir valores para os parâmetros, um processo chamado Binding.

Por padrão, a API Web usa as seguintes regras para associar parâmetros:

  • Se o parâmetro for um tipo "simples", a API Web tentará obter o valor do URI. Os tipos simples incluem os tipos primitivos do .net (int, bool, Double e assim por diante), mais TimeSpan, DateTime, GUID, decimal e String, além de qualquer tipo com um conversor de tipo que possa converter de uma cadeia de caracteres. (Saiba mais sobre os conversores de tipo mais tarde.)
  • Para tipos complexos, a API da Web tenta ler o valor do corpo da mensagem, usando um formatador de tipo de mídia.

Por exemplo, aqui está um método típico de controlador da API Web:

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

O parâmetro ID é um " " tipo simples, portanto, a API Web tenta obter o valor do URI de solicitação. O parâmetro Item é um tipo complexo, portanto, a API Web usa um formatador de tipo de mídia para ler o valor do corpo da solicitação.

Para obter um valor do URI, a API da Web procura os dados da rota e a cadeia de caracteres de consulta do URI. Os dados de rota são preenchidos quando o sistema de roteamento analisa o URI e o corresponde a uma rota. Para obter mais informações, consulte seleção de roteamento e ação.

No restante deste artigo, mostrarei como você pode personalizar o processo de associação de modelo. Para tipos complexos, no entanto, considere o uso de formatadores de tipo de mídia sempre que possível. Um princípio importante do HTTP é que os recursos são enviados no corpo da mensagem, usando a negociação de conteúdo para especificar a representação do recurso. Os formatadores de tipo de mídia foram projetados para exatamente essa finalidade.

Usando [FromUri]

Para forçar a API Web a ler um tipo complexo do URI, adicione o atributo [FromUri] ao parâmetro. O exemplo a seguir define um GeoPoint tipo, juntamente com um método de controlador que obtém o GeoPoint do URI.

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

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

O cliente pode colocar os valores de latitude e longitude na cadeia de caracteres de consulta e a API da Web irá usá-los para construir um GeoPoint . Por exemplo:

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

Usando [FromBody]

Para forçar a API Web a ler um tipo simples do corpo da solicitação, adicione o atributo [FromBody] ao parâmetro:

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

Neste exemplo, a API da Web usará um formatador de tipo de mídia para ler o valor do nome do corpo da solicitação. Aqui está um exemplo de solicitação 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"

Quando um parâmetro tem [FromBody], a API da Web usa o cabeçalho Content-Type para selecionar um formatador. Neste exemplo, o tipo de conteúdo é " Application/JSON " e o corpo da solicitação é uma cadeia de caracteres JSON bruta (não um objeto JSON).

No máximo um parâmetro tem permissão para ler a partir do corpo da mensagem. Portanto, isso não funcionará:

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

O motivo para essa regra é que o corpo da solicitação pode ser armazenado em um fluxo sem buffer que só pode ser lido uma vez.

Conversores de tipo

Você pode fazer com que a API da Web trate uma classe como um tipo simples (para que a API da Web tente associá-la a partir do URI) criando um TypeConverter e fornecendo uma conversão de cadeia de caracteres.

O código a seguir mostra uma GeoPoint classe que representa um ponto geográfico, além de um TypeConverter que converte de cadeias de caracteres em GeoPoint instâncias. A GeoPoint classe é decorada com um atributo [TypeConverter] para especificar o conversor de tipo. (Este exemplo foi inspirado pela postagem no blog de Mike Stall como associar a objetos personalizados em assinaturas de ação no 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);
    }
}

Agora, a API da Web tratará GeoPoint como um tipo simples, o que significa que tentará associar GeoPoint os parâmetros do URI. Você não precisa incluir [FromUri] no parâmetro.

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

O cliente pode invocar o método com um URI como este:

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

ASSOCIADORES de modelo

Uma opção mais flexível do que um conversor de tipo é criar um associador de modelo personalizado. Com um associador de modelo, você tem acesso a coisas como a solicitação HTTP, a descrição da ação e os valores brutos dos dados da rota.

Para criar um associador de modelo, implemente a interface IModelBinder . Essa interface define um único método, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Aqui está um associador de modelo para GeoPoint objetos.

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

Um associador de modelo obtém valores de entrada brutos de um provedor de valor. Esse design separa duas funções distintas:

  • O provedor de valor pega a solicitação HTTP e popula um dicionário de pares chave-valor.
  • O associador de modelo usa esse dicionário para preencher o modelo.

O provedor de valor padrão na API Web obtém valores dos dados de rota e da cadeia de caracteres de consulta. Por exemplo, se o URI for http://localhost/api/values/1?location=48,-122 , o provedor de valor criará os seguintes pares de chave-valor:

  • ID = " 1"
  • local = " 48,-122"

(Estou supondo que o modelo de rota padrão, que é " API/{Controller}/{ID} " .)

O nome do parâmetro a ser associado é armazenado na propriedade ModelBindingContext. ModelName . O associador de modelo procura uma chave com esse valor no dicionário. Se o valor existir e puder ser convertido em um GeoPoint , o associador de modelo atribuirá o valor associado à propriedade ModelBindingContext. Model .

Observe que o associador de modelo não está limitado a uma conversão de tipo simples. Neste exemplo, o associador de modelo primeiro procura em uma tabela de locais conhecidos e, se isso falhar, usará a conversão de tipo.

Configurando o associador de modelo

Há várias maneiras de definir um associador de modelo. Primeiro, você pode adicionar um atributo [ModelBinder] ao parâmetro.

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

Você também pode adicionar um atributo [ModelBinder] ao tipo. A API Web usará o associador de modelo especificado para todos os parâmetros desse tipo.

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

Por fim, você pode adicionar um provedor de associador de modelo ao HttpConfiguration. Um provedor de associador de modelo é simplesmente uma classe de fábrica que cria um associador de modelo. Você pode criar um provedor derivando da classe ModelBinderProvider . No entanto, se o seu associador de modelo tratar de um único tipo, será mais fácil usar o SimpleModelBinderProvider interno, que foi projetado para essa finalidade. O código a seguir mostra como fazer isso.

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

        // ...
    }
}

Com um provedor de associação de modelo, você ainda precisa adicionar o atributo [ModelBinder] ao parâmetro para informar à API Web que ele deve usar um associador de modelo e não um formatador de tipo de mídia. Mas agora você não precisa especificar o tipo de associador de modelo no atributo:

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

Provedores de valor

Mencionei que um associador de modelo obtém valores de um provedor de valor. Para gravar um provedor de valor personalizado, implemente a interface IValueProvider . Aqui está um exemplo que efetua pull dos valores dos cookies na solicitação:

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

Você também precisa criar um alocador de provedor de valor derivando da classe ValueProviderFactory .

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

Adicione o alocador de provedor de valor ao HttpConfiguration da seguinte maneira.

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

    // ...
}

A API Web compõe todos os provedores de valor, portanto, quando um associador de modelo chama ValueProvider. GetValue, o associador de modelo recebe o valor do provedor de primeiro valor que é capaz de produzi-lo.

Como alternativa, você pode definir o alocador de provedor de valor no nível de parâmetro usando o atributo ValueProvider , da seguinte maneira:

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

Isso informa à API da Web para usar a associação de modelo com o alocador de provedor de valor especificado e não para usar qualquer um dos outros provedores de valor registrado.

HttpParameterBinding

Os vinculadores de modelo são uma instância específica de um mecanismo mais geral. Se você olhar o atributo [ModelBinder] , verá que ele deriva da classe abstrata ParameterBindingAttribute . Essa classe define um método único, GetBinding, que retorna um objeto HttpParameterBinding :

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

Um HttpParameterBinding é responsável por associar um parâmetro a um valor. No caso de [ModelBinder], o atributo retorna uma implementação de HttpParameterBinding que usa um IModelBinder para executar a associação real. Você também pode implementar seu próprio HttpParameterBinding.

Por exemplo, suponha que você queira obter ETags de if-match if-none-match cabeçalhos e na solicitação. Vamos começar definindo uma classe para representar ETags.

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

Também definiremos uma enumeração para indicar se deve obter a ETag do if-match cabeçalho ou do if-none-match cabeçalho.

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Aqui está um HttpParameterBinding que obtém a eTag do cabeçalho desejado e a associa a um parâmetro do 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;
    }
}

O método ExecuteBindingAsync faz a associação. Dentro desse método, adicione o valor do parâmetro Bound ao dicionário ActionArgument no HttpActionContext.

Note

Se o método ExecuteBindingAsync ler o corpo da mensagem de solicitação, substitua a propriedade WillReadBody para retornar true. O corpo da solicitação pode ser um fluxo sem buffer que só pode ser lido uma vez, portanto, a API Web impõe uma regra que, no máximo, uma associação pode ler o corpo da mensagem.

Para aplicar um HttpParameterBinding personalizado, você pode definir um atributo que deriva de ParameterBindingAttribute. Para ETagParameterBinding , vamos definir dois atributos, um para if-match cabeçalhos e outro para if-none-match cabeçalhos. Ambos derivam de uma classe base abstrata.

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

Aqui está um método de controlador que usa o [IfNoneMatch] atributo.

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

Além de ParameterBindingAttribute, há outro gancho para adicionar um HttpParameterBinding personalizado. No objeto HttpConfiguration , a propriedade ParameterBindingRules é uma coleção de funções anônimas do tipo (HttpParameterDescriptor - > HttpParameterBinding). Por exemplo, você pode adicionar uma regra que qualquer parâmetro de ETag em um método GET usa ETagParameterBinding com 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;
    }
});

A função deve retornar null para parâmetros em que a associação não é aplicável.

IActionValueBinder

O processo de vinculação de parâmetro inteiro é controlado por um serviço conectável, IActionValueBinder. A implementação padrão de IActionValueBinder faz o seguinte:

  1. Procure um ParameterBindingAttribute no parâmetro. Isso inclui [FromBody], [FromUri] e [ModelBinder], ou atributos personalizados.

  2. Caso contrário, examine HttpConfiguration. ParameterBindingRules para uma função que retorna um HttpParameterBinding não nulo.

  3. Caso contrário, use as regras padrão que descrevi anteriormente.

    • Se o tipo de parâmetro for "simples" ou tiver um conversor de tipo, associe a partir do URI. Isso é equivalente a colocar o atributo [FromUri] no parâmetro.
    • Caso contrário, tente ler o parâmetro no corpo da mensagem. Isso é equivalente a colocar [FromBody] no parâmetro.

Se desejar, você poderia substituir todo o serviço IActionValueBinder por uma implementação personalizada.

Recursos adicionais

Exemplo de associação de parâmetro personalizado

Mike Stall escreveu uma boa série de postagens no blog sobre associação de parâmetro da API Web: