Еще немного о валидации в ASP.NET
В прошлый раз я перенес часть императивного кода в атрибут. Есть еще одна проверка, кочующая из одного файла в другой:
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 из контекста.