Validação de Entrada em Minimal APIs
Objective
Tudo o que chega por HTTP é entrada não confiável. Sem uma checagem, um cliente
pode criar um jogo sem nome, com preço negativo ou com um título de dez mil
caracteres, e o dado ruim fica no banco até outra coisa quebrar por causa dele.
O objetivo é declarar as regras de validação uma vez, no tipo da requisição, e
fazer o ASP.NET Core rejeitar requisições inválidas com 400 antes de o seu
handler rodar. A partir do .NET 10, minimal APIs têm suporte embutido para isso,
e ele vem com algumas armadilhas que valem ser conhecidas antes de depender dele.
Use Cases
- Rejeitar um
POST /gamescom nome ausente, nome acima de 50 caracteres ou preço fora da faixa permitida, com uma mensagem por campo. - Validar um parâmetro de query ou de rota (
?page=0) sem escrever umifem cada handler. - Dar ao front end uma lista legível por máquina dos campos que falharam, para um formulário destacá-los.
- Manter os handlers livres de ruído de validação, para que lidem só com entrada válida.
Deep Dive
O bug que motiva isso
Minimal APIs não validam nada por padrão. Esta requisição é aceita e gravada:
textPOST /games { "genreId": 1, "price": 59.99, "releaseDate": "2023-10-20" } // sem name HTTP/1.1 201 Created
Dá para checar cada propriedade à mão no handler, mas isso se repete em todo endpoint e em todo campo, e esconde a lógica de verdade.
Declare as regras com data annotations
Coloque as regras no tipo que modela a requisição. Os atributos ficam em
System.ComponentModel.DataAnnotations:
csharppublic record CreateGameDto(
[Required][StringLength(50)] string Name,
[Range(1, 50)] int GenreId,
[Range(1, 100)] decimal Price,
DateOnly ReleaseDate);Os mais comuns são [Required], [StringLength(max)], [Range(min, max)],
[RegularExpression], [EmailAddress] e [MinLength]/[MaxLength] para
coleções. O UpdateGameDto repete os mesmos atributos: compartilhar um tipo
entre criar e atualizar é tentador, mas os dois divergem assim que um campo
passa a ser somente leitura depois da criação.
Ligue: AddValidation
Atributos num tipo não fazem nada até a validação ser registrada. No .NET 10, isso é uma linha:
csharpbuilder.Services.AddValidation();
A partir daí, o framework valida todo parâmetro de todo handler de minimal API
que tenha atributos de validação, antes de chamar o handler. Uma falha
interrompe com 400 Bad Request e um corpo de problema de validação que lista
cada campo que falhou:
plaintext{ "title": "One or more validation errors occurred.", "errors": { "Name": ["The Name field is required."], "Price": ["The field Price must be between 1 and 100."] } }
Note que as chaves são os nomes das propriedades C# (Name), não camelCase, e
que todas as falhas voltam juntas, então um formulário pode marcar todos os
campos ruins de uma vez. Parâmetros também são validados:
([Range(1, 100)] int page) rejeita ?page=0 no mesmo formato. O handler nunca
é invocado, então pode assumir que a entrada é válida.
Uma armadilha silenciosa: tipos que não são descobertos
No .NET 10 a configuração de validação encontra os tipos a validar em tempo de
compilação. Num projeto de teste, um CreateDto declarado como tipo
internal (um record sem modificador public, no fim do Program.cs)
nunca foi validado. Uma requisição com nome vazio e preço 500 voltou 200 OK,
enquanto o mesmo record tornado public devolveu 400 com os dois erros.
Parâmetros como [Range] int page continuaram funcionando, o que torna a falha
fácil de passar despercebida.
A regra prática: mantenha os DTOs de requisição public e escreva um teste por
DTO que envia um corpo inválido e espera 400. Se um tipo precisar ficar
internal, consulte a documentação atual sobre [ValidatableType] e sobre como a
descoberta funciona na sua versão.
O que anotações não conseguem expressar
Data annotations checam o formato de um valor. Elas não conhecem o banco:
[Range(1, 50)]emGenreIdnão prova que o gênero 17 existe. Se não existir, oSaveChangesAsynclança umaDbUpdateExceptionpela foreign key e o cliente vê um500. Cheque a existência no handler e responda400ou422você mesmo.- Regras que comparam dois campos (data final depois da inicial) precisam de
IValidatableObjectou de umValidationAttributecustomizado. - Unicidade ("já existe um jogo com esse nome") pertence ao banco, como um
índice único, com a violação traduzida para
409 Conflict.
Mensagens e regras customizadas
Todo atributo aceita um ErrorMessage, e uma regra customizada é uma subclasse
pequena:
csharppublic sealed class NotFutureAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext context) =>
value is DateOnly d && d > DateOnly.FromDateTime(DateTime.UtcNow)
? new ValidationResult("Release date cannot be in the future.")
: ValidationResult.Success;
}Trade-offs
- Atributos misturam regras no tipo do contrato. É compacto e as regras ficam visíveis ao lado do campo, mas o DTO passa a conhecer validação. Uma biblioteca como o FluentValidation mantém as regras numa classe separada e lida melhor com regras condicionais ou entre campos, ao custo de uma dependência a mais e de configuração.
- Um validador que passa não é uma requisição segura. A validação checa o formato e as faixas que você listou. Ela não diz nada sobre autorização, existência de linhas relacionadas ou invariantes de negócio, que continuam pertencendo ao handler ou ao domínio.
- Skip silencioso é pior que falha barulhenta. O caso do tipo internal acima
devolve
200com dado ruim e nada nos logs. Testes que verificam400para um corpo inválido são a única rede de segurança confiável. - Regras duplicadas divergem. Copiar
[StringLength(50)]do DTO de criação para o de atualização são dois lugares para mudar. Se os dois compartilham as regras de verdade, extraia uma base comum ou uma constante:csharppublic const int NameMaxLength = 50; // usada pelos dois DTOs e pela configuração do EF - Faixas de decimal são fáceis de errar.
[Range(1, 100)]numdecimalcompara por meio de uma conversão; para limites fracionários use[Range(typeof(decimal), "0.01", "100")]. E alinhe o tamanho da coluna do banco ao atributo ([StringLength(50)]eHasMaxLength(50)), ou um dos dois está mentindo.