IValidationAttributeAdapterProvider is called only for EmailAddressAttribute - asp.net-core

What I was doing with ASP.NET MVC 5
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(MaxLengthAttribute), typeof(MyMaxLengthAttributeAdapter));
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(RequiredAttribute), typeof(MyRequiredAttributeAdapter));
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(MinLengthAttribute), typeof(MyMinLengthAttribute));
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(EmailAddressAttribute), typeof(MyEmailAddressAttributeAdapter));
Now I'm migrating it to ASP.NET core 6
We can't use DataAnnotationsModelValidatorProvider anymore so I'm trying to use IValidationAttributeAdapterProvider, which doesn't work properly for me.
My codes
My IValidationAttributeAdapterProvider is below.
public class MyValidationAttributeAdapterProvider : ValidationAttributeAdapterProvider, IValidationAttributeAdapterProvider
{
IAttributeAdapter? IValidationAttributeAdapterProvider.GetAttributeAdapter(
ValidationAttribute attribute,
IStringLocalizer? stringLocalizer)
{
return attribute switch
{
EmailAddressAttribute => new MyEmailAddressAttributeAdapter((EmailAddressAttribute)attribute, stringLocalizer),
MaxLengthAttribute => new MyMaxLengthAttributeAdapter((MaxLengthAttribute)attribute, stringLocalizer),
MinLengthAttribute => new MyMinLengthAttribute((MinLengthAttribute)attribute, stringLocalizer),
RequiredAttribute => new MyRequiredAttributeAdapter((RequiredAttribute)attribute, stringLocalizer),
_ => base.GetAttributeAdapter(attribute, stringLocalizer),
};
}
}
My model class is below.
public class LogInRequestDTO
{
[Required]
[EmailAddress]
[MaxLength(FieldLengths.Max.User.Mail)]
[Display(Name = "mail")]
public string? Mail { get; set; }
[Required]
[MinLengthAttribute(FieldLengths.Min.User.Password)]
[DataType(DataType.Password)]
[Display(Name = "password")]
public string? Password { get; set; }
}
And in my Program.cs, I do like below.
builder.Services.AddControllersWithViews()
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) => factory.Create(typeof(Resources));
});
builder.Services.AddSingleton<IValidationAttributeAdapterProvider, MyValidationAttributeAdapterProvider>();
What happed to me
I expect GetAttributeAdapter is called for each attribute like EmailAddressAttribute, MaxLengthAttribute, etc.
But it's called only once with EmailAddressAttribute.
So, all other validation results are not customized by my adaptors.
If I remove [EmailAddress] from the model class, GetAttributeAdapter is never called.
Am I missing something?
Added on 2022/05/24
What I want to do
I want to customize all the validation error message.
I don't want to customize for one by one at the place I use [EmailAddress] for example.
I need the server side validation only. I don't need the client side validation.
Reproducible project
I created the minimum sample project which can reproduce the problem.
https://github.com/KuniyoshiKamimura/IValidationAttributeAdapterProviderSample
Open the solution with Visual Studio 2022(17.2.1).
Set the breakpoint on MyValidationAttributeAdapterProvider.
Run the project.
Input something to the textbox on the browser and submit it.
The breakpoint hits only once with EmailAddressAttribute attribute.
The browser shows the customized message for email and default message for all other validations.

Below is a work demo, you can refer to it.
In all AttributeAdapter, change your code like below.
public class MyEmailAddressAttributeAdapter : AttributeAdapterBase<EmailAddressAttribute>
{
// This is called as expected.
public MyEmailAddressAttributeAdapter(EmailAddressAttribute attribute, IStringLocalizer? stringLocalizer)
: base(attribute, stringLocalizer)
{
//attribute.ErrorMessageResourceType = typeof(Resources);
//attribute.ErrorMessageResourceName = "ValidationMessageForEmailAddress";
//attribute.ErrorMessage = null;
}
public override void AddValidation(ClientModelValidationContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
MergeAttribute(context.Attributes, "data-val", "true");
MergeAttribute(context.Attributes, "data-val-must-be-true", GetErrorMessage(context));
}
// This is called as expected.
// And I can see the message "Input the valid mail address.".
public override string GetErrorMessage(ModelValidationContextBase validationContext)
{
return GetErrorMessage(validationContext.ModelMetadata, validationContext.ModelMetadata.GetDisplayName());
}
}
In homecontroller:
public IActionResult Index()
{
return View();
}
[HttpPost]
public IActionResult Index([FromForm][Bind("Test")] SampleDTO dto)
{
return View();
}
Index view:
#model IV2.Models.SampleDTO
#{
ViewData["Title"] = "Index";
}
<h1>Index</h1>
<h4>SampleDTO</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="Index">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Test" class="control-label"></label>
<input asp-for="Test" class="form-control" />
<span asp-validation-for="Test" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>
#section Scripts {
#{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
Result1:
Result2:

I found the solution.
What I have to use is not ValidationAttributeAdapterProvider but IValidationMetadataProvider.
This article describes the usage in detail.
Note that some attributes including EmailAddressAttribute have to be treated in special way as describe here because they have default non-null ErrorMessage.
I confirmed for EmailAddressAttribute and some other attributes.
Also, there's the related article here.

Related

Razor Pages - Return Error on Duplicate Name

I'm working on a Razor Pages form that takes in a string to create a new customer in a SQL Server Database. I want to make it work so that if the string that is the customer already exists, a prompt comes up that says "This Customer Already Exists". Just to be safe for data integrity.
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
int customerCheck = -1; //No Customer ID is -1
try
{
using (var context = new DataWarehouseContext())
{
customerCheck = context.Customer //Tries to grab a Customer with this name
.Where(a => a.Name == Customer.name)
.Select(b => b.CustomerId)
.FirstOrDefault();
}
}
catch (Exception)
{
}
if(customerCheck == -1)
{
_context.Customer.Add(Customer);
await _context.SaveChangesAsync();
return RedirectToPage("/Customer/List");
}
else
{
return Page();
}
}
This is the code I have so far in my backend. What happens is that when a user tries to create a new customer, the backend of the page tries to see if it can grab a customer ID that correlates to this name. If it can, then the value of customerCheck is not -1, therefore some error should get printed out.
I don't know what methods can be used to do this, so any help would be great!
I found a solution, and it wasn't hard to implement. When a duplicate customer was found in the backend, I create a ModelState.AddModelError object and fill it with a key and a description of the error. Next, in the frontend, I put it within an H3 tag to print it out like so:
Backend OnPost() Code
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
int customerCheck = 0; //No Customer ID is 0
try
{
using (var context = new DataWarehouseContext())
{
customerCheck = context.Customer //Tries to grab a Customer with this name
.Where(a => a.Name == Customer.name)
.Select(b => b.CustomerId)
.FirstOrDefault();
}
}
catch (Exception)
{
}
if(customerCheck == 0)
{
_context.Customer.Add(Customer);
await _context.SaveChangesAsync();
return RedirectToPage("/Customer/List");
}
else
{
ModelState.AddModelError("DuplicateCustomer", "This Customer Already Exists");
return Page();
}
}
So on the frontend, it gets implemented like this:
<h3 align="center" style="color:yellowgreen">#Html.ValidationMessage("DuplicateCustomer")</h3>
When return Page(); is hit, the page is reloaded and the DuplicateCustomer Error appears.
At first, glad to hear you have found a solution.
Besides, I think you could also use the Remote Validation to check whether the Customer is exist or not. Check the following sample code:
Remote validation in ASP.NET (Core) relies on Unobtrusive AJAX, so you will need to install that first. The easiest way to do this is via LibMan. Right click on the lib folder in wwwroot, choose Add ยป Client-side Library, and then choose jsdelivr as the source, and type in jquery-ajax-unobtrusive, click the "Install" button to install the package.
In the CreateCustomer.cshtml.cs page, add a Email property and use the PageRemote attribute, then, add a handler method to perform the validation.
public class CreateCustomerModel : PageModel
{
private readonly IRepository _repository;
public CreateCustomerModel(IRepository repository)
{
_repository = repository;
}
[PageRemote(ErrorMessage = "Email Address already exists", AdditionalFields = "__RequestVerificationToken", HttpMethod = "post",PageHandler = "CheckEmail")]
[BindProperty]
public string Email { get; set; }
public void OnGet()
{
}
public IActionResult OnPost()
{
if (ModelState.IsValid)
{
//insert data into database.
}
return Page();
}
#pragma warning disable MVC1001 // Filters cannot be applied to page handler methods.
[ValidateAntiForgeryToken]
#pragma warning restore MVC1001 // Filters cannot be applied to page handler methods.
public JsonResult OnPostCheckEmail()
{
//query database and check whether the email is exist or not.
var existingEmails = _repository.GetCustomers().Select(c => c.Email.ToLower()).ToList();
var valid = !existingEmails.Contains(Email.ToLower());
return new JsonResult(valid);
}
In the CreateCustomer.cshtml razor page, add JQuery reference and add a form to enter the values.
#page
#model RazorSample.Pages.CreateCustomerModel
#{
}
<div class="row">
<div class="col-md-4">
<form method="post" asp-antiforgery="true">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Email" class="control-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
#* add other fields *#
<div class="form-group">
<input type="submit" value="Create" class="btn btn-primary" />
</div>
</form>
</div>
</div>
#section scripts{
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<partial name="_ValidationScriptsPartial" />
<script src="~/lib/jquery-ajax-unobtrusive/jquery.unobtrusive-ajax.min.js"></script>
}
After submit the button, the result as below: if the email is exist, it will show the prompt:
[Note] In the above sample, we are adding the properties in the PageModel (instead of nested model), and use it to validate the field. Because, if using nested object, we might meet the 400 Bad Request result. The 400 error is related to the AntiForgeryToken, if you meet this error, try to ignore validate the AntiForgeryToken or custom add the __RequestVerificationToken token at the body or header, check this link.
More detail information about Remote Validation in Razor Pages, check the following articles:
Remote Validation in Razor Pages
Improved Remote Validation in Razor Pages

Input radio buttons in ASP.NET Core MVC with taghelpers

I am trying to create a form with a radio button with two values : Automatic and Manual.
In order to do so I adapted an answer to a GitHub issue to my code but unfortunately I have an issue in the line foreach of the View where "Model.GearingType" is not recognized and if I change it to "GearingType" it's not recognized either.
Thanks !
ViewModel
public class EvaluationForm
{
public enum GearingType
{
Manual,
Automatic
}
[Required(ErrorMessage = "Please select your car gearing's type")]
[Display(Name = "Gearing Type")]
public GearingType SelectedGearingType { get; set; }
View
<div class="row">
<div class="col-md">
#{
foreach (Model.GearingType gearType in Enum.GetValues(typeof(Model.GearingType))
{
<label>
<input asp-for="SelectedGearingType" type="radio" value="#gearType" />
#gearType
</label>
}
}
</div>
</div>
You need to specify the Type, not the instance of the model in the typeof statement
foreach (var gearType in Enum.GetValues(typeof(EvaluationForm.GearingType)))
{
....
}

MVC : Pass values from textbox to controller action

I am new to MVC.Basically I need to pass values entered in the textbox from my view to controller action method. As I enter the values in the text box and click the enter button I need to display the value on the screen. I am currently unable to do so. Please find my code below
The model class
public class ProteinTrackingService
{
public int? Total { get; set; }
public int Goal { get; set; }
public void AddProtein(int? amount)
{
Total += amount;
}
}
The controller class
public class ProteinTrackerController : Controller
{
ProteinTrackingService proteinTrackingService = new ProteinTrackingService();
// GET: ProteinTracker
public ActionResult Index()
{
ViewBag.Total = proteinTrackingService.Total;
ViewBag.Goal = proteinTrackingService.Goal;
return View();
}
// GET: ProteinTracker/Details/5
public ActionResult AddProtein(ProteinTrackingService model)
{
proteinTrackingService.AddProtein(model.Total);
ViewBag.Total = proteinTrackingService.Total;
ViewBag.Goal = proteinTrackingService.Goal;
return View("Index");
}
}
The view
using (Html.BeginForm("ProteinTracker", "AddProtein",FormMethod.Post))
{
#Html.AntiForgeryToken()
<form>
<div class="form-horizontal">
<h4>Protein Tracker</h4>
<hr />
Total : #ViewBag.Total
Goal : #ViewBag.Goal
<input id="Text1" type="text" value="TextInput" /> <input type="Submit" value="Add" />
</div>
</form>
}
I am modifying the code above based on your suggestions. I basically need to display the following in the view
Total : value
Goal : value
Textbox control (To enter the total) Button (pass the total to contoller) Please note that when the user clicks the Add button the total should show in above field Total : value.
New View
#using (Html.BeginForm( "AddProtein","ProteinTracker", FormMethod.Post))
{
#Html.AntiForgeryToken()
<div class="form-horizontal">
<h4>Protein Tracker</h4>
<hr />
#Html.LabelFor(m => m.Total, "Total" ) <hr />
#Html.LabelFor(m => m.Goal, "Goal") <hr />
#Html.TextBoxFor(m => m.Total) <hr />
<input type="Submit" value="Add" />
</div>
}
New Controller
public class ProteinTrackerController : Controller
{
ProteinTrackingService proteinTrackingService = new ProteinTrackingService();
// GET: ProteinTracker
public ActionResult Index()
{
var model = new ProteinTrackingService()
{ Total = proteinTrackingService.Total, Goal = proteinTrackingService.Goal };
return View(model);
}
// GET: ProteinTracker/Details/5
public ActionResult AddProtein(ProteinTrackingService model)
{
proteinTrackingService.AddProtein(model.Total);
model.Total = proteinTrackingService.Total;
model.Goal = proteinTrackingService.Goal;
return View("Index",model);
}
}
You need to add the HttpPost attribute to your action.Looking at your form #using (Html.BeginForm( "AddProtein","ProteinTracker", FormMethod.Post)) , apparently you are sending a post request to your controller.
[HttpPost]
public ActionResult AddProtein(ProteinTrackingService model)
{
proteinTrackingService.AddProtein(model.Total);
model.Total = proteinTrackingService.Total;
model.Goal = proteinTrackingService.Goal;
return View("Index",model);
}
First of all your this syntax
using (Html.BeginForm("ProteinTracker", "AddProtein", FormMethod.Post))
already creates a form tag when html generates. No need to create from tag again in it.
So for your want, in view you need give to your input field a name
<input id="Text1" type="text" value="TextInput" name="textname"/>
and add this name as parameter in your controller method like that
public ActionResult AddProtein(ProteinTrackingService model,string textname)
{
// your code
return View("Index");
}
It will pass your textfield value from view to controller. For clearing your concept you may visit Loopcoder.com

MVC form Post deserialization is incomplete for complex model object

Using MVC 4 Forms, I have a model that always contains four children in a List<T> property. The view displays the model correctly with each of the four child models rendered with a Razor partial view. The problem is that when I submit/post, the model deserializes with a null value for the child list.
Model:
public class MyModel
{
public int SomeValue { get; set; }
public List<ChildModel> Children { get; set; }
...
}
View:
#model MyProject.Models.MyModel
#using (Html.BeginForm())
{
#Html.LabelFor(model => model.SomeValue)
#Html.Partial("ChildPartial", Model.Children[0])
#Html.Partial("ChildPartial", Model.Children[1])
#Html.Partial("ChildPartial", Model.Children[2])
#Html.Partial("ChildPartial", Model.Children[3])
<input type="submit" value="Save" />
}
Controller:
public class MyController : Controller
{
public ActionResult Index()
{
MyModel model = new MyModel();
model.Children = new List<ChildModel>();
model.Children.Add(new ChildModel());
model.Children.Add(new ChildModel());
model.Children.Add(new ChildModel());
model.Children.Add(new ChildModel());
return View(model);
}
[HttpPost]
public ActionResult Index(MyModel model)
{
//model.Children is null here
//do stuff
...
return RedirectToAction("Index", "SomeOtherController");
}
}
The ChildPartial views are each rendering correctly, and I am entering values into the controls, but they are not deserialized into the List<ChildModel>. I can only get the root level properties of MyModel to deserialize in the Post method.
I have tried adding UpdateModel(model); to the beginning of the controller Post method but no luck there. Any ideas?
Edit
ChildModel.cs:
public class ChildModel
{
public String Name { get; set; }
public double Ratio { get; set; }
...
}
ChildPartial.cshtml:
#model MyProject.Models.ChildModel
<div>
<div>
<div>
<span>#Model.Name</span>
</div>
<div>
#Html.LabelFor(m => m.Ratio)
#Html.TextBoxFor(m => m.Ratio, new { autocomplete = "off" })
#Html.ValidationMessageFor(m => m.Ratio)
</div>
</div>
...
</div>
I would first recommend you reading about the specific syntax that the default model binder expects and the naming convention when binding to collections: http://haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx
Once you compare the names of your input fields with the ones explained in this blog post you will pretty quickly understand why your code doesn't work. You are simply not following the standard naming convention.
In order to fix this I would recommend you using editor templates. So in your main view put the following:
#model MyProject.Models.MyModel
#using (Html.BeginForm())
{
#Html.LabelFor(model => model.SomeValue)
#Html.EditorFor(model => model.Children)
<input type="submit" value="Save" />
}
Then move your ChildPartial.cshtml to ~/Views/Shared/EditorTemplates/ChildModel.cshtml. Notice that the name of the template and the location is extremely important. Make sure you have followed it. And put this inside:
#model MyProject.Models.ChildModel
<div>
<div>
<div>
<span>#Model.Name</span>
</div>
<div>
#Html.LabelFor(m => m.Ratio)
#Html.TextBoxFor(m => m.Ratio, new { autocomplete = "off" })
#Html.ValidationMessageFor(m => m.Ratio)
</div>
</div>
...
</div>
Alright, now run your project, inspect the generated HTML and more specifically the names of the input fields compare them with your initial version and compare them to the blog post I have initially linked to in my answer and you will understand everything about how model binding to collections works in ASP.NET MVC.
Remark: in your child template you don't have a corresponding input field for the Name property of your ChildModel. So don't be surprised if it is null in your controller. You simply never send a value to it when the form is submitted. If you want this to happen you could include it as a hidden field in your editor template:
#Html.HiddenFor(m => m.Name)

Same url but different controllers?

Is it possible to use two different controllers for the same url?
This is needed because I need the URL to always remain the same, but it should use different controllers. My controllers (Apples, Bananas, etc.) and views are separated into each own project.
I need a action in my main MVC project to return a action/view from either the Bananas or Apples project depending on some logic.
So how would I go ahead to always have the same url but return actions/views from different controllers?
I'm using MVC 4
Your URLs should be where the logic for selecting your controller is. Maybe you need to reorganise your project to have a single controller and put the other logic in the controller action for filling the model?
However, if you insist on going this route you will likely need to override CreateController in the DefaultControllerFactory, this is the class that instantiates your controller, usually based on your controller name. Here is an example in one of my projects:
public class ErrorHandlingControllerFactory : DefaultControllerFactory
{
/// <summary>
/// Injects a custom attribute
/// on every action that is invoked by the controller
/// </summary>
/// <param name="requestContext">The request context</param>
/// <param name="controllerName">The name of the controller</param>
/// <returns>An instance of a controller</returns>
public override IController CreateController(
RequestContext requestContext,
string controllerName)
{
var controller =
base.CreateController(requestContext,
controllerName);
var c = controller as Controller;
if (c != null)
{
c.ActionInvoker =
new ErrorHandlingActionInvoker(
new HandleErrorWithELMAHAttribute());
}
return controller;
}
}
You will need to set your route up to pass a known controller name (horrible magic strings...), test for this controller name, and if detected run your logic to get the actual controller name and pass this in to base.CreateController.
I wrote these codes. I hope that it helps you. I used hidden field to understand which method will run.
these are my models:
namespace MvcSameController.Models
{
public class RouteModel
{
public SampleModel1 SampleModel1 { get; set; }
public SampleModel2 SampleModel2 { get; set; }
}
public class SampleModel1
{
public int Id { get; set; }
public string Name { get; set; }
}
public class SampleModel2
{
public int Id { get; set; }
public string Surname { get; set; }
}
}
this is controller:
using System.Web.Mvc;
using MvcSameController.Models;
namespace MvcSameController.Controllers
{
public class SameController : Controller
{
//
// GET: /Same/
public ActionResult Index()
{
return View();
}
[HttpPost]
public void Index(RouteModel routeModel, string type)
{
if (type == "1")
{
//Code for type 1
}
else if (type == "2")
{
//Code for type 2
}
}
}
}
and view :
#{
ViewBag.Title = "Index";
}
#model MvcSameController.Models.RouteModel
<section id="loginForm">
<h2>Type1 </h2>
#using (Html.BeginForm())
{
#Html.AntiForgeryToken()
#Html.ValidationSummary(true)
#Html.Hidden("type",1)
<fieldset>
<legend>Type1 Form</legend>
<ol>
<li>
#Html.LabelFor(m => m.SampleModel1.Name)
#Html.TextBoxFor(m => m.SampleModel1.Name)
#Html.ValidationMessageFor(m => m.SampleModel1.Name)
</li>
</ol>
<input type="submit" value="Run Method1" />
</fieldset>
}
</section>
<section id="loginForm">
<h2>Type2</h2>
#using (Html.BeginForm())
{
#Html.AntiForgeryToken()
#Html.ValidationSummary(true)
#Html.Hidden("type",2)
<fieldset>
<legend>Type2 Form</legend>
<ol>
<li>
#Html.LabelFor(m => m.SampleModel2.Surname)
#Html.TextBoxFor(m => m.SampleModel2.Surname)
#Html.ValidationMessageFor(m => m.SampleModel2.Surname)
</li>
</ol>
<input type="submit" value="Run Method2" />
</fieldset>
}
</section>
you can download my sample from here