Partilhar via


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

Considere usar ASP.NET Core API Web. Ele tem as seguintes vantagens sobre ASP.NET API Web 4.x:

  • ASP.NET Core é uma estrutura multiplataforma de software livre para criar aplicativos Web modernos baseados em nuvem no Windows, macOS e Linux.
  • Os controladores de MVC ASP.NET Core e os controladores de API 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 pronto para a nuvem, baseado no ambiente.
  • 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, Apache e 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 associação.

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), além de TimeSpan, DateTime, Guid, decimal e string, além de qualquer tipo com um conversor de tipo que possa converter de uma cadeia de caracteres. (Mais informações sobre conversores de tipo posteriormente.)
  • Para tipos complexos, a API 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 de 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 Web examina os dados de rota e a cadeia de caracteres de consulta de URI. Os dados de rota são preenchidos quando o sistema de roteamento analisa o URI e os 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. No entanto, para tipos complexos, considere usar formatadores de tipo de mídia sempre que possível. Um princípio fundamental 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. Formatadores de tipo de mídia foram projetados exatamente para 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 Latitude e Longitude na cadeia de caracteres de consulta e a API Web os usará 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 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 Web usa o cabeçalho Tipo de Conteúdo 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 do corpo da mensagem. Portanto, isso não funcionará:

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

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

Conversores de tipo

Você pode fazer com que a API Web trate uma classe como um tipo simples (para que a API Web tente associá-la 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 na 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 Web tratará GeoPoint como um tipo simples, o que significa que ela tentará associar GeoPoint 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 itens como a solicitação HTTP, a descrição da ação e os valores brutos dos dados de 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 usa a solicitação HTTP e preenche 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 chave-valor:

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

(Supondo que o modelo de rota padrão seja "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 examina uma tabela de locais conhecidos e, se isso falhar, ele usará a conversão de tipo.

Definindo 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 associador de modelo manipular 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 escrever um provedor 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;
    }
}

Você também precisa criar uma fábrica de provedores 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 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 primeiro provedor de valor que é capaz de produzi-lo.

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

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

Isso instrui a API Web a usar a associação de modelo com a fábrica do provedor de valor especificada e não usar nenhum dos outros provedores de valor registrados.

HttpParameterBinding

Os associadores de modelo são uma instância específica de um mecanismo mais geral. Se você examinar o atributo [ModelBinder] , verá que ele deriva da classe abstract ParameterBindingAttribute . Essa classe define um único método, 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 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 cabeçalhos e if-none-match na solicitação. Começaremos definindo uma classe para representar ETags.

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

Também definiremos uma enumeração para indicar se deseja 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 o ETag do cabeçalho desejado e o 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 associado ao dicionário ActionArgument no HttpActionContext.

Observação

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 cofres 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, definiremos 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 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

Todo o processo de associação de parâmetros é 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, procure em HttpConfiguration.ParameterBindingRules 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-se do URI. Isso equivale a colocar o atributo [FromUri] no parâmetro .
    • Caso contrário, tente ler o parâmetro do corpo da mensagem. Isso é equivalente a colocar [FromBody] no parâmetro .

Se você quisesse, 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 a associação de parâmetros da API Web: