Partilhar via


Ligação de parâmetros na API Web ASP.NET

Considere usar ASP.NET API web Core. Tem as seguintes vantagens sobre ASP.NET API Web 4.x:

  • ASP.NET Core é uma framework de código aberto e multiplataforma para construir aplicações web modernas baseadas na cloud no Windows, macOS e Linux.
  • Os controladores ASP.NET Core MVC e os controladores web API estão unificados.
  • Arquitetado para a testabilidade.
  • Capacidade de desenvolver e correr em Windows, macOS e Linux.
  • Código aberto e focado na comunidade.
  • Integração de frameworks modernos do lado do cliente e fluxos de trabalho de desenvolvimento.
  • Um sistema de configuração pronto para a cloud e baseado no ambiente.
  • Injeção de dependência incorporada.
  • Um pipeline de pedidos HTTP leve, de alto desempenho e modular.
  • Capacidade de hospedar em Kestrel, IIS, HTTP.sys, Nginx, Apache e Docker.
  • Controle de versões simultâneas.
  • Ferramentas que simplificam o desenvolvimento web moderno.

Este artigo descreve como a Web API vincula parâmetros e como pode personalizar o processo de ligação. Quando a Web API chama um método num controlador, esta deve definir valores para os parâmetros, um processo chamado binding.

Por defeito, a Web API utiliza as seguintes regras para atribuir parâmetros:

  • Se o parâmetro for do tipo "simples", a Web API tenta obter o valor do URI. Tipos simples incluem os tipos primitivos .NET (int, bool, double, e assim por diante), mais TimeSpan, DateTime, Guid, decimal e string, além de qualquer tipo com um conversor de tipos que possa converter a partir de uma string. (Falarei mais sobre conversores de tipos mais à frente.)
  • Para tipos complexos, a Web API tenta ler o valor do corpo da mensagem, usando um formatador de tipos de media.

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

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

O parâmetro id é do tipo "simples", por isso a Web API tenta obter o valor do URI do pedido. O parâmetro item é de tipo complexo, por isso a Web API utiliza um formatador do tipo media para ler o valor do corpo do pedido.

Para obter um valor do URI, a Web API verifica os dados da rota e a cadeia de consulta do URI. Os dados da rota são preenchidos quando o sistema de encaminhamento analisa o URI e o associa a uma rota. Para mais informações, consulte Seleção de Roteamento e Ações.

No resto deste artigo, vou mostrar-lhe como pode personalizar o processo de vinculação de modelos. Para tipos complexos, no entanto, considere usar formatadores de tipos de media sempre que possível. Um princípio fundamental do HTTP é que os recursos são enviados no corpo da mensagem, usando negociação de conteúdo para especificar a representação do recurso. Os formateadores do tipo media foram concebidos exatamente para este propósito.

A usar [FromUri]

Para forçar a Web API a ler um tipo complexo do URI, adicione o atributo [FromUri] ao parâmetro. O exemplo seguinte define um GeoPoint tipo, juntamente com um método 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 consulta e a Web API irá usá-los para construir um GeoPoint. Por exemplo:

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

A usar [FromBody]

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

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

Neste exemplo, a Web API usará um formador do tipo media para ler o valor do nome do corpo do pedido. Aqui está um exemplo de pedido 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 Web API utiliza o cabeçalho Content-Type para selecionar um formatador. Neste exemplo, o tipo de conteúdo é "application/json" e o corpo do pedido é uma string JSON bruta (não um objeto JSON).

No máximo, um parâmetro é permitido para ler do corpo da mensagem. Portanto, isto não vai funcionar:

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

A razão para esta regra é que o corpo do pedido pode ser armazenado num fluxo não bufferizado que só pode ser lido uma vez.

Conversores de Tipos

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

O código seguinte mostra uma GeoPoint classe que representa um ponto geográfico, mais um TypeConverter que converte de strings para GeoPoint instâncias. A GeoPoint classe é decorada com um atributo [TypeConverter] para especificar o conversor de tipos. (Este exemplo foi inspirado no artigo de blog de Mike Stall, How to bind to custom objects in action signatures 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);
    }
}

Agora, a Web API tratará GeoPoint como um tipo simples, o que significa que tentará associar parâmetros GeoPoint do URI. Não precisas de incluir [FromUri] no parâmetro.

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

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

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

Associadores de Modelos

Uma opção mais flexível do que um conversor de tipos é criar um binder personalizado. Com um binder de modelos, tens acesso a coisas como o pedido HTTP, a descrição da ação e os valores brutos dos dados da rota.

Para criar um binder de modelos, implemente a interface IModelBinder . Esta interface define um único método, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Aqui está um fichário de modelos 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 binder de modelos obtém valores brutos de entrada de um fornecedor de valor. Este design separa duas funções distintas:

  • O fornecedor de valor recebe o pedido HTTP e preenche um dicionário de pares-chave-valor.
  • O vinculador de modelos utiliza este dicionário para preencher o modelo.

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

  • id = "1"
  • localização = "48,-122"

(Estou a assumir o modelo de rota padrão, que é "api/{controller}/{id}".)

O nome do parâmetro a ligar está armazenado na propriedade ModelBindingContext.ModelName . O model binder procura uma chave com o valor correspondente no dicionário. Se o valor existir e puder ser convertido em um GeoPoint, o "binder" do modelo atribui o valor vinculado à propriedade ModelBindingContext.Model.

Note que o vinculador de modelo não está limitado a uma simples conversão de tipo. Neste exemplo, o associador de modelos procura primeiro numa tabela de localizações conhecidas e, caso esta falhe, utiliza a conversão de tipos.

Configuração do Vinculador de Modelo

Existem várias formas de configurar um vinculador de modelo. Primeiro, podes adicionar um atributo [ModelBinder] ao parâmetro.

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

Também pode adicionar um atributo [ModelBinder] ao tipo. A Web API utilizará o binder de modelo especificado para todos os parâmetros desse tipo.

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

Finalmente, pode adicionar um fornecedor de associador de modelos ao HttpConfiguration. Um fornecedor de model binder é simplesmente uma classe de fábrica que cria um model binder. Pode criar um fornecedor derivando da classe ModelBinderProvider . No entanto, se o seu associador de modelo gerir um único tipo, é mais fácil usar o SimpleModelBinderProvider incorporado, que é projetado para este propósito. 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 fornecedor de ligação de modelos, ainda precisa de adicionar o atributo [ModelBinder] ao parâmetro, para indicar à Web API que deve usar um ligador de modelo e não um formatador de tipo de mídia. Mas agora já não é necessário especificar o tipo de associador de modelos no atributo.

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

Fornecedores de Valor

Mencionei que um binder de modelos obtém valores de um fornecedor de valor. Para escrever um fornecedor de valor personalizado, implemente a interface IValueProvider . Aqui está um exemplo que extrai 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;
    }
}

Também precisa de criar uma fábrica de fornecedor de valor derivando da classe ValueProviderFactory .

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

Adicione a fábrica do fornecedor de valor ao HttpConfiguration da seguinte forma.

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

    // ...
}

Web API compõe todos os fornecedores de valor, por isso, quando um binder de modelos chama ValueProvider.GetValue, o binder de modelos recebe o valor do primeiro fornecedor de valor que consegue produzi-lo.

Em alternativa, pode definir a fábrica de fornecedores de valor ao nível dos parâmetros usando o atributo ValueProvider , da seguinte forma:

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

Isto instrui a API da Web a usar a vinculação de modelos com a fábrica de provedores de valor especificada, e para não utilizar nenhum dos outros provedores de valor registados.

HttpParameterBinding

Os binders modelo são um exemplo específico de um mecanismo mais geral. Se olhar para o atributo [ModelBinder ], verá que deriva da classe abstrata ParameterBindingAttribute . Esta classe define um único método, GetBinding, que devolve um objeto HttpParameterBinding :

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

Um HttpParameterBinding é responsável por ligar um parâmetro a um valor. No caso de [ModelBinder], o atributo devolve uma implementação HttpParameterBinding que utiliza um IModelBinder para realizar a ligação real. Também pode implementar o seu próprio HttpParameterBinding.

Por exemplo, suponha que quer obter ETags nos cabeçalhos if-match e if-none-match do pedido. Vamos começar por definir uma classe para representar ETags.

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

Também iremos definir uma enumeração para indicar se devemos obter o ETag do cabeçalho if-match ou do cabeçalho if-none-match.

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Aqui está um HttpParameterBinding que obtém o ETag do cabeçalho desejado e o liga 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 ligação. Neste método, adicione o valor do parâmetro limitado ao dicionário ActionArgument no HttpActionContext.

Observação

Se o seu método ExecuteBindingAsync leia o corpo da mensagem de requisição, sobreponha a propriedade WillReadBody para devolver true. O corpo do pedido pode ser um fluxo sem buffer que só pode ser lido uma vez, pelo que a Web API impõe uma regra que, no máximo, uma ligação pode ler o corpo da mensagem.

Para aplicar um HttpParameterBinding personalizado, 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 controlador que usa o [IfNoneMatch] atributo.

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

Além do ParameterBindingAttribute, existe 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, poderia adicionar uma regra que qualquer parâmetro ETag num método GET use 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 os parâmetros em que a associação não seja aplicável.

IActionValueBinder

Todo o processo de ligação de parâmetros é controlado por um serviço pluggable, IActionValueBinder. A implementação padrão do IActionValueBinder faz o seguinte:

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

  2. Caso contrário, procure em HttpConfiguration.ParameterBindingRules uma função que devolve 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 tipos, associe a partir do URI. Isto equivale a colocar o atributo [FromUri] no parâmetro.
    • Caso contrário, tente ler o parâmetro do corpo da mensagem. Isto equivale a colocar [FromBody] no parâmetro.

Se quiseres, podes substituir todo o serviço IActionValueBinder por uma implementação personalizada.

Recursos adicionais

Amostra de Vinculação de Parâmetro Personalizado

Mike Stall escreveu uma boa série de artigos no blogue sobre a ligação de parâmetros da Web API: