Еще немного о валидации в ASP.NET

habr.png

В прошлый раз я перенес часть императивного кода в атрибут. Есть еще одна проверка, кочующая из одного файла в другой:

public class MoveProductParam
{
   public ProductId {get; set; }

   public CategoryId {get; set; }
}

//...
if(!dbContext.Products.Any(x => x.Id == par.ProductId))
    return BadRequest("Product not found");

if(!dbContext.Categories.Any(x => x.Id == par.CategoryId ))
    return BadRequest("Category not found");


Мы достойны лучшего

public class MoveProductParam
{
   [EntityId(typeof(Product))]
   public ProductId {get; set; }

   [EntityId(typeof(Category))]
   public CategoryId {get; set; }
}


Добавим Атрибут для валидации


Метод IsValid у ValidationAttribute перегружен. Нам потребуется вторая перегрузка, чтобы дотянуться до IOC-контейнера. Для простоты я не стал вводить дополнительный интерфейс и просто пытаюсь получить делегат, который по типу и Id вернет булево значение: есть такая сущность или нет.

public class EntityIdAttribute: ValidationAttribute  
{
	private readonly Type _entityType;

	public EntityIdAttribute(Type entityType)
	{
		_entityType = entityType;
	}

        protected override ValidationResult IsValid(object value,
            ValidationContext validationContext)
        {
            var checker = validationContext
                .GetService(typeof(Func))
                as Func
                ?? throw new InvalidOperationException(
                    "You must register Func");

            return checker(_entityType, value)
                ? ValidationResult.Success
                : new ValidationResult($"Entity with id {value} is not found");
        }
}


Регистрируем делегат


Доставать всю сущность из контекста может быть не эффективно, если вы не планируете манипулировать ей в дальнейшем. Если планируете, то проблем нет: она сохранится в кеше EF. В любом случае конкретная реализация функции зависит от вашего DAL и ее всегда можно заменить.

services.AddScoped>(x =>
{
	bool Func(Type t, object o)
	{
		var dbContext = x.GetService();
		return dbContext.Find(t, o) != null;
	};

	return Func;
});


Собираем вместе


public class MoveProductParam
{
   [EntityId(typeof(Product))]
   public ProductId {get; set; }

   [EntityId(typeof(Category))]
   public CategoryId {get; set; }
}

[Validate]
public class ProductController: Controller
{
   public IActionResult Move(MoveProductParam par)
   {
      // Ваша логика здесь.
      // Этот код выполнится, только если ProductId и CategoryId есть в БД
      // Все проверки стали декларативными
   }
}


Да, атрибут можно забыть поставить, тогда при передаче несуществующего id мы упадем с NRE или ArgumentException, если вы используете защитное программирование. Такую ошибку будет очень легко диагностировать и исправить. Если MoveProductParam используется более чем в одном месте валидация применится везде, где этот параметр используется. Вы не забудете добавить проверку повторно.

UPD. Развиваем идею. Добавляем ModelBinder


mayorovpподсказал в комментариях, что можно пойти дальше и сделать ModelBinder. Чтобы можно было так:

public class MoveProductParam
{
   [ModelBinder(typeof(EntityModelBinder))]
   public Product Product{get; set; }

   [ModelBinder(typeof(EntityModelBinder))]
   public Category Category{get; set; }
}


Сказано — сделано. К сожалению в метод Find нельзя передать строку из запроса. Нужно знать тип первичного ключа. Скорее всего эта информация доступна из контекста бд, но я так глубоко не копал, поэтому вытащил тип свойства Id. Чтобы было побыстрее добавил TypeAccessor из FastMember.

public class EntityModelBinder: IModelBinder
{
	private readonly Func _getter;

	public EntityModelBinder(Func getter)
	{
		_getter = getter;
	}

	public Task BindModelAsync(ModelBindingContext bindingContext)
	{
		var value = bindingContext.ActionContext
			.HttpContext
			.Request
			.Query[bindingContext.ModelName]
			.FirstOrDefault();

		if (value == null)
		{
			bindingContext.ModelState
                             .AddModelError(bindingContext.ModelName,
                             "Id for \"bindingContext.ModelName\" is null");
			return Task.CompletedTask;
		}

		try
		{
			var typeAccessor = TypeAccessor
                            .Create(bindingContext.ModelType);
			
			// Не все называеют Id так.
                        // Лучше покопаться в конфигурации EF, чтобы вытащить тип PK
			// Если кто подскажет в комментариях где это буду благодарен

			var id = Convert.ChangeType(value,
                            typeAccessor.GetMembers()
                                .First(y => y.Name == "Id")
                                .Type);
			
                        var result = _getter(bindingContext.ModelType, id);
			bindingContext.Result = ModelBindingResult.Success(result);
		}
		catch (Exception e)
		{
			bindingContext.ModelState.AddModelError(
                            bindingContext.ModelName, e.Message);
		}
		
		return Task.CompletedTask;
	}

//Сигнатура делегата только в качестве примера. Может быть другой интерфейс
// с явной семантикой
services.AddScoped>(x =>
{
	object Func(Type t, object o)
	{
		var typeAccessor = TypeAccessor.Create(t);
		var dbContext = x.GetService();
		return dbContext.Find(t, o);
	};
	
	return Func;
});


Осталось реализовать IModelBinderProvider, чтобы назначить этот байндер на все Entity из контекста.

© Habrahabr.ru