Agora que você sabe que sempre utilizar tipos primitivos é uma má prática, talvez você esbarre na dificuldade de adotar os seus próprios tipos (classes, structs ou records) aos seus endpoints. Nem sempre o ASP.Net consegue fazer uma tradução direta. E pior ainda fica pra documentar. Antes de você trocar tudo para string, integer e bool, permite que eu te mostre como adicionar tipos customizáveis na API.
Mas primeiro…
Eu sei que eu acabamos de falar sobre Obsessão por tipos primitivos e seus males. No entanto, você vai perceber que customizar o ASP.Net pode não ser uma tarefa simples. Então eu preciso dizer que você não pode se sentir na obrigação de criar essa estrutura para todos os tipos da sua aplicação. As vezes receber uma string e fazer a conversão no próprio controller também é uma decisão aceitável. Como eu sempre digo: Arquitetura e design de software são sempre sobre trade-offs. E você precisa ter certeza que está adotando a solução menos prejudicial dado o cenário atual.
Outro ponto que quero levantar é que, na versão 8 do ASP.Net, com a introdução de novas bibliotecas e do namespace Microsoft.AspNetCore.OpenApi essa tarefa ficou muito mais simples. E alguns bugs de documentação foram ajustados (coisa que não aconteceu com a versão 6, que neste mês, perde o suporte da Microsoft). Então se as coisas estão dando errado, espere um pouco ou atualize sua API. Do contrário, vai ter que fazer muita coisa na mão mesmo.
Minha classe ChartAccountsCode
Pra começar a nossa brincadeira, estou trazendo aqui um conceito da contabilidade. Toda empresa possui um “Plano de Contas”. Esse plano lista e estrutura todas as contas que a empresa pode ter. Essas contas descrevem, por exemplo, Movimentações financeiras, Contas a receber, Ativo imobilizado entre outros. E essas contas vão se especializando – como se fossem ramos de uma árvore – até chegar na menor granularidade desejada. Por exemplo: Contas a Receber poderia ter um subnível “Clientes” e outro “Clientes premium”. Geralmente essas contas são identificadas por números:
1. Ativos
- 1.1 Caixa e Equivalentes de Caixa
- 1.1.1 Caixa
- 1.1.2 Banco Conta Movimento
- 1.1.3 Aplicações Financeiras de Curto Prazo
- 1.2 Contas a Receber
- 1.2.1 Clientes
- 1.2.2 Devedores Diversos
- 1.2.3 Provisão para Devedores Duvidosos
- 1.3 Estoques
- 1.3.1 Matérias-primas
- 1.3.2 Produtos Acabados
- 1.3.3 Mercadorias para Revenda
Para representar o código de cada conta, nós escrevemos a classe abaixo:
public readonly struct ChartAccountsCode
{
private readonly string _value;
public ChartAccountsCode(string value)
{
_value = value;
}
public override string ToString()
{
return _value;
}
public static bool TryParse(string? value, out ChartAccountsCode accountId)
{
accountId = default;
try
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
accountId = new ChartAccountsCode(value);
return true;
}
catch
{
return false;
}
}
public static implicit operator string(ChartAccountsCode accountId) =>
accountId.ToString();
public static implicit operator ChartAccountsCode(string accountId) =>
new(accountId);
}
Nós poderíamos adicionar alguma validação, uma vez que o código tem um formato muito bem definido. Mas para focar mais na nossa solução, decidi não fazer isso nesse momento. Eu apenas quero chamar sua atenção para alguns pontos:
Não temos uma propriedade!
O nosso tipo está completamente fechado. Nós o declaramos como readonly struct
, porque queremos que ele se comporte como um tipo básico e imutável (além de alguns ganhos de performance). Você poderia adicionar uma propriedade .Value
à struct
. Mas ela deixaria de fazer sentido – e no melhor dos cenário não seria usada – por causa do que vem à seguir:
Sobrescrevemos os operadores implícitos
Os dois métodos estáticos da classe estão sobrescrevendo o comportamento das conversões implícitas entre string
e ChartAccountsCode
. Essa sobrescrita permite que operações como atribuição e comparação sejam resolvidas automágicamente, tornando o código abaixo perfeitamente válido:
const string AccountId = "1.11.111.1111";
ChartAccountsCode id = AccountId;
if (id == AccountId)
{
Console.WriteLine("São iguais");
}
else
{
Console.WriteLine("São diferentes");
}
Console.WriteLine(id);
Isso é bastante divertido, não é mesmo? Mesmo com essa facilidade, eu ainda sobrescreveria o método .Equals
, já que sempre teremos alguém que vai preferir escrever: if (id.Equals(AccountId))
e nesse caso, o retorno será false.
Mas como eu disse, você pode se sentir livre pra continuar utilizando uma propriedade qualquer.
Adicionando o ChartAccountsCode
à nossa API
Chegou o momento esperado! Nesse artigo, eu vou utilizar as clássicas Controller
s do ASP.Net. Gosto dessa abordagem porque sistemas mais complexos geralmente evitam o uso das Minimal API por questões de legibilidade e não deixar seu código parecido com node.js. Nossa controller terá um endpoint para cada caso de uso:
[ApiController]
[Route("api/[controller]")]
public class GeneralLedgerController : ControllerBase
{
[HttpGet()]
public IActionResult GetFromQuery([FromQuery] ChartAccountsCode accountId)
{
return Ok(accountId);
}
[HttpGet("from-property/")]
public IActionResult GetFromQueryOnProperty([FromQuery] RequestAccountDetails request) =>
Ok(request.AccountId);
[HttpGet("{accountId}")]
public IActionResult GetFromRoute([FromRoute] ChartAccountsCode accountId)
{
return Ok(accountId);
}
[HttpPost]
public IActionResult FromPost([FromBody] ChartAccountsCode accountId) =>
Ok(accountId);
[HttpPost("from-request")]
public IActionResult FromPostRequest([FromBody] RequestAccountDetails request) =>
Ok(request);
}
Apenas para testar, para cada request, estamos retornado 200 - Ok
como status code e o objeto recebido como response.
Como você pode perceber, existem alguns problemas no resultado final. Primeiro, veja que a documentação não é clara quanto ao formato do que é esperado pelo request. Apesar de todo o nosso esforço para que o ChartAccountsCode
fosse tratado como string
, o Swashbuckle está documentando como um objeto. E ainda assim, enfrentamos o erro:
System.InvalidOperationException: Could not create an instance of type ‘TypeConverterSample.Api.ChartAccountsCode’. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, give the ‘accountId’ parameter a non-null default value.
Você poderia seguir a solução sugerida pela exceção, porém estaríamos indo para o caminho contrário do que desejamos. Uma curiosidade? Eu pedi ao Chat GPT para que ele descrevesse o gif acima para pessoas com limitação na visão. Depois pedi para ele ser “menos detalhista” e focar no que ele “acreditava” ser o sentido do gif. Ele me sugeriu criar um TypeConverter:
Vamos seguir esse conselho?
Criando um TypeConverter para o ChartAccountsCode
TypeConverters são classes, dentro do ASP.Net Core, que possibilitam a conversão de tipos básicos para tipos complexos. Ou seja: Você pode construir a sua controller, especificando tipos complexos, mas o usuário, quando chamar o endpoint, poderá passar o conteúdo como um tipo básico (uma string, por exemplo) e o ASP.Net Core se encarregará de fazer a conversão.
Vamos alterar o nosso tipo básico, adicionando o método estático TryParse
:
public static bool TryParse(string? value, out ChartAccountsCode accountId)
{
accountId = default;
try
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
accountId = new ChartAccountsCode(value);
return true;
}
catch
{
return false;
}
}
Faz parte do vocabulário da linguagem C# que alguns tipos possuam o método TryParse
com uma assinatura semelhante a que acabamos de escrever. Trata-se de uma forma bastante elegante de delegar ao próprio tipo a responsabilidade de saber converter-se à partir de outros. Contudo seria bastante complicado para o framework adivinhar sozinho quais métodos TryParse
a classe/struct possui e até mesmo qual a sua assinatura.
Bom, pra ser sincero, nem seria tão difícil assim. Contudo, teríamos um problema de performance: Para descobrir sozinho qual método TryParse
utilizar (isso, caso ele exista), o ASP.Net precisaria fazer uso de reflection. E código com reflection não consegue ser otimizado com AOT. Inclusive se você tentar compilar o seu código, qualquer parte com reflections irá gerar vários warnings no output.
A solução de design proposta foi a criação dos TypeConverter
. Essa classe tem a dupla missão de abstrair a pergunta “Posso converter esse tipo de origem no tipo de destino esperado?” e abstrair o processo de instanciação do tipo de destino.
Vamos ver o código final que vai ficar mais simples de você entender:
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TypeConverterSample.Api;
[TypeConverter(typeof(ChartAccountsCodeConverter))]
public readonly struct ChartAccountsCode
{
private readonly string _value;
public ChartAccountsCode(string value)
{
_value = value;
}
public override string ToString()
{
return _value;
}
public static bool TryParse(string? value, out ChartAccountsCode accountId)
{
accountId = default;
try
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
accountId = new ChartAccountsCode(value);
return true;
}
catch
{
return false;
}
}
public static implicit operator string(ChartAccountsCode accountId) =>
accountId.ToString();
public static implicit operator ChartAccountsCode(string accountId) =>
new(accountId);
private class ChartAccountsCodeConverter : 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)
{
var stringValue = value as string;
if (TryParse(stringValue, out var accountId))
return accountId;
return base.ConvertFrom(context, culture, value);
}
}
private class ChartAccountsCodeJsonConverter : JsonConverter<ChartAccountsCode>
{
public override ChartAccountsCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new ChartAccountsCode(reader.GetString() ?? string.Empty);
}
public override void Write(Utf8JsonWriter writer, ChartAccountsCode value, JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
}
}
Neste exemplo, eu criei uma classe nested privada. Mas no seu projeto, talvez seja melhor criar em um arquivo separado. O Sonarqube geralmente não gosta de duas classes no mesmo arquivo.
A nossa classe ChartAccountsCode
recebe o atributo TypeConverterAttribute
, recebendo no construtor o tipo correspondente ao nosso TypeConverter Implementado.
[TypeConverter(typeof(ChartAccountsCodeConverter))]
public readonly struct ChartAccountsCode
Por sua vez, como podemos ver, a classe implementa os dois métodos da classe abstrata TypeConverter<>
, que possibilitam a conversão. Nesse momento, nossa API já está respondendo de forma distinta:
Após a implementação do TypeConverter
parece ter dado certo, já que o erro não acontece mais. Porém, o retorno está esquisito. Ainda é um objeto vazio! Como corrigir isso?
Implementando JsonConverter
Agora é hora de dizer ao framework como ele deve Serializar/Desserializar o nosso ChartAccountsCode
. Afinal, é exatamente esse o problema. Normalmente um processo de serialização tomaria uma instância e faria a representação de cada propriedade no formato esperado (json no nosso caso). Mas não é isso que queremos com a nossa classe. Ela sequer possui propriedades para que sejam representadas. Como ensinamos ao framework? JsonConverter
. Vejamos o código:
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TypeConverterSample.Api;
public class ChartAccountsCodeJsonConverter : JsonConverter<ChartAccountsCode>
{
public override ChartAccountsCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new ChartAccountsCode(reader.GetString() ?? string.Empty);
}
public override void Write(Utf8JsonWriter writer, ChartAccountsCode value, JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
}
Você poderia seguir a mesma estratégia do TypeConverter
, utilizando uma classe privada, mas dessa vez eu tentei não buscar tanta briga com o Sonarqube.
O código é bastante simples e intuitivo. Em Read
, nós estamos transformando do tipo de origem (reader.GetString()
nos facilita a vida) par ao tipo de destino. Já em Write, estamos fazendo o inverso, ao receber a instância de ChartAccountsCode
e transformá-la em string.
Com essas alterações, nossa API funciona normalmente:
Porém, se você reparar bem, vai perceber que há algo de errado na forma como a documentação foi gerada.
Este é um problema para o próximo artigo.
Gostou deste artigo? Compartilhe com outros desenvolvedores e confira nossos outros posts!