RequiredIf - Estenda a validação no Blazor

Imagem de capa RequiredIf - Estenda a validação no Blazor

RequiredIf - Estenda a validação no Blazor

Muitas vezes estou escrevendo sobre tópicos que encontro em minha vida diária. Então imagine que você tem um blog... como este que você está lendo agora. Isso tem algumas propriedades. Simplificado, seu modelo pode ficar assim:

public  class  BlogPostModel
{
    [Required]
    public  string Title { get; set; }
   
    [Required]
    public  string ShortDescription { get; set; }
   
    [Required] 
    public  string Content { get; set; }
   
    [Required] 
    public  string PreviewImageUrl { get; set; }
   
    [Required] 
    public  bool IsPublished { get; set; } = true;
}

Isso parece muito bom. Basicamente, o usuário (neste caso: eu) precisa preencher todas as propriedades, caso contrário o formulário não será enviado. Mas e se a postagem do blog não for publicada IsPublished == false. Ainda temos que fornecer todas as informações? Infelizmente sim. O Blazor não nos oferece nenhuma possibilidade de fornecer qualquer contexto ao atributo Required. Se Blazor não, então nós fazemos!

RequiredIf

O objetivo é que queremos expressar:

"O conteúdo só é obrigatório, se publicarmos o post do blog!"

Então vamos começar com a notação. Eu quero algo assim:

public  class  BlogPostModel 
{
    [Required] 
    public  string Title { get; set; }
   
    [RequiredIf(nameof(IsPublished), true)] 
    public  string Content { get; set; }
   
    [Required] 
    public  bool IsPublished { get; set; } 
}

Agora aqui algumas coisas:

  • O título ainda é obrigatório. Eu não quero mudar isso
  • O conteúdo só deve ser obrigatório se IsPublished for verdadeiro

Por que o nome da notação. Bem, os atributos são legais e legais, mas ficam para trás em alguns recursos-chave. Por exemplo genéricos. Um atributo não pode ter genéricos. Então temos que contornar isso. Para poder ser usado para validação, temos que pelo menos estender ValidationAttribute. Dê uma olhada na documentação aqui. Você pode ver que existem muitos atributos derivados.

Então vamos lá, o construtor está praticamente pronto:

public  class  RequiredIfAttribute : ValidationAttribute 
{
     private  readonly  string _propertyName; 
     private  readonly  object? _isValue;
    
     public RequiredIfAttribute(string propertyName, object? isValue) 
     {
         _propertyName = propertyName ?? throw  new ArgumentNullException(nameof(propertyName));
         _isValue = isValue;
     }

Bem, isso foi fácil .. o trabalho pesado seguirá. Mas primeiro vamos substituir a mensagem de erro. Também queremos indicar na mensagem de erro que existem algumas dependências. Podemos fazer isso substituindo FormatErrorMessage

public override string FormatErrorMessage(string name) 
{
     var errorMessage = $"Property {name} is required when {_propertyName} is {_isValue}";
     return ErrorMessage ?? errorMessage;
}

No nosso caso com a postagem do blog, queremos dizer: O conteúdo da propriedade é obrigatório quando IsPublished não é True.

Agora a função central: ValidationResult? IsValid(objeto? valor, ValidationContext validationContext). (Aqui para mais informações) Este é chamado para cada propriedade com um RequiredAttribute. Portanto, este é o nosso ponto de entrar e fazer o trabalho pesado.

A primeira coisa que queremos fazer é: Verificar se temos todas as informações. Isso também inclui, nosso propertyName que obtivemos via nameof(IsPublished) realmente existe?

protected  override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
   ArgumentNullException.ThrowIfNull(validationContext);
   var property = validationContext.ObjectType.GetProperty(_propertyName);
  
   if (property == null)
   {
       throw  new NotSupportedException($"Can't find {_propertyName} on searched type: {validationContext.ObjectType.Name}"); 
   }  

Acho que a única linha que precisa de um pouco mais de explicação é a seguinte: var property = validationContext.ObjectType.GetProperty(_propertyName);

O ValidationContext.ObjectType contém nosso modelo (BlogPostModel). Isso é importante porque precisamos resolver a dependência da nossa propriedade. Portanto, precisamos do tipo e também da instância concreta para obter o valor real de IsPublished. Portanto, este trecho nos fornece as informações de tipo.

var requiredIfTypeActualValue = property.GetValue(validationContext.ObjectInstance);

if (requiredIfTypeActualValue == null && _isValue != null)
{ 
    return ValidationResult.Success;
}

var requiredIfNotTypeActualValue = property.GetValue(validationContext.ObjectInstance); faz exatamente o que eu escrevi antes. Queremos obter o valor real do nosso "ponteiro" RequiredIf. Novamente, no nosso caso, queremos ter IsPublished.

Depois fazemos uma verificação rápida se o valor real é nulo e o esperado não é nulo. Ambos seriam nulos, consideraríamos a propriedade como necessária. Se não podemos sair aqui e dizer: "Tudo bem!". E agora resta exatamente isso: E se a propriedade for necessária? Bem, então só temos que verificar se está definido ou não:

if (requiredIfTypeActualValue == null || requiredIfTypeActualValue.Equals(_isValue))
{
    return  value == null
        ? new ValidationResult(FormatErrorMessage(validationContext.DisplayName))
        : ValidationResult.Success;
}

return ValidationResult.Success;

O inteiro if não faz nada além de verificar se nosso IsPublished é nulo ou o valor necessário. Em caso afirmativo, verificamos se o valor é nulo. Se assim for, isso seria uma violação e leva a um resultado de erro. Por que usei requiredIfTypeActualValue.Equals(_isValue) em vez de requiredIfTypeActualValue == _isValue. Se você verificar os tipos, eles são todos objetos. Isso significa que == verificaria ReferenceEquals. Não é tão bom se usarmos bool.

Links e recursos

Agora, se você quiser usar isso e muito mais: atualmente estou construindo uma biblioteca que abriga essa funcionalidade auxiliar. Também estou planejando adicionar dependências fora do modelo.

Se você quiser ver o código mais os testes: Aqui você vai no github. E aqui diretamente o pacote nuget.

A coisa toda

using System.ComponentModel.DataAnnotations;

namespace  LinkDotNet.ValidationExtensions;

public  class  RequiredIfAttribute : ValidationAttribute
{
    private  readonly  string _propertyName; 
    private  readonly  object? _isValue;
   
    public RequiredIfAttribute(string propertyName, object? isValue)
    {
        _propertyName = propertyName ?? throw  new ArgumentNullException(nameof(propertyName));
        _isValue = isValue;
    }
   
    public  override  string  FormatErrorMessage(string name)
    {
        var errorMessage = $"Property {name} is required when {_propertyName} is {_isValue}";
        return ErrorMessage ?? errorMessage;
     }
    
     protected  override ValidationResult? IsValid(object? value, ValidationContext validationContext)
     {
         ArgumentNullException.ThrowIfNull(validationContext);
         var property = validationContext.ObjectType.GetProperty(_propertyName);
        
         if (property == null)
         {
             throw  new NotSupportedException($"Can't find {_propertyName} on searched type: {validationContext.ObjectType.Name}");
          }
         
          var requiredIfTypeActualValue = property.GetValue(validationContext.ObjectInstance);
         
          if (requiredIfTypeActualValue == null && _isValue != null)
          {
              return ValidationResult.Success;
           }
          
           if (requiredIfTypeActualValue == null || requiredIfTypeActualValue.Equals(_isValue))
           {
               return  value == null
                   ? new ValidationResult(FormatErrorMessage(validationContext.DisplayName))
                   : ValidationResult.Success;
            }
            
            return ValidationResult.Success;
       }
}