📜 ⬆️ ⬇️

More about validation in ASP.NET

Last time, I transferred part of the imperative code to an attribute. There is one more test, moving from one file to another:

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"); 

We deserve better
 public class MoveProductParam { [EntityId(typeof(Product))] public ProductId {get; set; } [EntityId(typeof(Category))] public CategoryId {get; set; } } 

Add Attribute for Validation


The IsValid method of the ValidationAttribute overloaded. We need a second overload to reach the IOC container. For simplicity, I did not introduce an additional interface and just try to get a delegate that by type and Id will return a boolean value: is there such an entity or not.

 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<Type, object, bool>)) as Func<Type, object, bool> ?? throw new InvalidOperationException( "You must register Func<Type, object, bool>"); return checker(_entityType, value) ? ValidationResult.Success : new ValidationResult($"Entity with id {value} is not found"); } } 

Register delegate


Getting the whole entity out of context may not be effective if you do not plan to manipulate it in the future. If you plan, then there are no problems: it will remain in the EF cache. In any case, the specific implementation of the function depends on your DAL and can always be replaced.
')
 services.AddScoped<Func<Type, object, bool>>(x => { bool Func(Type t, object o) { var dbContext = x.GetService<DbContext>(); return dbContext.Find(t, o) != null; }; return Func; }); 

Putting it together


 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    //     } } 

Yes, you can forget to set the attribute, then when transferring a non-existent id, we will fall from NRE or ArgumentException if you use defensive programming. This error will be very easy to diagnose and fix. If the MoveProductParam used in more than one place, the validation will apply wherever this parameter is used. You will not forget to add verification again.

UPD. Develop the idea. Add ModelBinder


mayorovp suggested in the comments that you can go ahead and make ModelBinder . So that you can:

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

No sooner said than done. Unfortunately, the Find method cannot pass a string from the query. You need to know the type of primary key. Most likely this information is available from the context of the database, but I did not dig so deeply, so I pulled out the property type Id . That was quicker added TypeAccessor from FastMember .

 public class EntityModelBinder: IModelBinder { private readonly Func<Type, object, object> _getter; public EntityModelBinder(Func<Type, object, object> 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<Func<Type, object, object>>(x => { object Func(Type t, object o) { var typeAccessor = TypeAccessor.Create(t); var dbContext = x.GetService<DemoContext>(); return dbContext.Find(t, o); }; return Func; }); 

It remains to implement IModelBinderProvider to assign this binder to all Entity from the context.

Source: https://habr.com/ru/post/346850/


All Articles