How to create a model binder for decimal numbers which will throw exception if users are sending it in a wrong format?
I need something like this:
2 = OK
2.123 = OK
2,123 = throw invalid format exception
Look at this article http://haacked.com/archive/2011/03/19/fixing-binding-to-decimals.aspx/
You can just use standard binder with simple check like this
public class DecimalModelBinder : IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
ValueProviderResult valueResult = bindingContext.ValueProvider
.GetValue(bindingContext.ModelName);
ModelState modelState = new ModelState { Value = valueResult };
object actualValue = null;
if (valueResult.AttemptedValue.Contains(","))
{
throw new Exception("Some exception");
}
actualValue = Convert.ToDecimal(valueResult.AttemptedValue,
CultureInfo.CurrentCulture);
bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
bindingContext.Model = actualValue;
return true;
}
}
EDIT: According to #Liam suggestion you have to add this binder to your configuration first
ModelBinders.Binders.Add(typeof(decimal), new DecimalModelBinder());
Above code throws exception in the case of bad decimal separater, but you should use model validation to detect that kind of errors. It is more flexible way.
public class DecimalModelBinder : IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
ValueProviderResult valueResult = bindingContext.ValueProvider
.GetValue(bindingContext.ModelName);
ModelState modelState = new ModelState { Value = valueResult };
object actualValue = null;
try
{
if (valueResult.AttemptedValue.Contains(","))
{
throw new Exception("Some exception");
}
actualValue = Convert.ToDecimal(valueResult.AttemptedValue,
CultureInfo.CurrentCulture);
}
catch (FormatException e)
{
modelState.Errors.Add(e);
return false;
}
bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
bindingContext.Model = actualValue;
return true;
}
}
you don't throw exception but just add validation error. You can check it in your controller later
if (ModelState.IsValid)
{
}
Related
I want to apply some preprocessing to raw data before it assigned to model properties. Namely to replace comma with dot to allow converting both this strings "324.32" and "324,32" into double. So I wrote this model binder
public class MoneyModelBinder: IModelBinder
{
private readonly Type _modelType;
public MoneyModelBinder(Type modelType)
{
_modelType = modelType;
}
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
string modelName = bindingContext.ModelName;
ValueProviderResult providerResult = bindingContext.ValueProvider.GetValue(modelName);
if (providerResult == ValueProviderResult.None)
{
return TaskCache.CompletedTask;
}
bindingContext.ModelState.SetModelValue(modelName, providerResult);
string value = providerResult.FirstValue;
if (string.IsNullOrEmpty(value))
{
return TaskCache.CompletedTask;
}
value = value.Replace(",", ".");
object result;
if(_modelType == typeof(double))
{
result = Convert.ToDouble(value, CultureInfo.InvariantCulture);
}
else if(_modelType == typeof(decimal))
{
result = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
}
else if(_modelType == typeof(float))
{
result = Convert.ToSingle(value, CultureInfo.InvariantCulture);
}
else
{
throw new NotSupportedException($"binder doesn't implement this type {_modelType}");
}
bindingContext.Result = ModelBindingResult.Success(result);
return TaskCache.CompletedTask;
}
}
then appropriate provider
public class MoneyModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if(context.Metadata?.ModelType == null)
{
return null;
}
if (context.Metadata.ModelType.In(typeof(double), typeof(decimal), typeof(float)))
{
return new MoneyModelBinder(context.Metadata.ModelType);
}
return null;
}
}
and registering it inside Startup.cs
services.AddMvc(options =>
{
options.ModelBinderProviders.Insert(0, new MoneyModelBinderProvider());
});
but I noticed some strange behavior or maybe I missed something. If I use this kind of action
public class Model
{
public string Str { get; set; }
public double Number { get; set; }
}
[HttpPost]
public IActionResult Post(Model model)
{
return Ok("ok");
}
and supply parameters inside query string everything works fine: first provider is called for model itself then for every property of the model. But if I use [FromBody] attribute and supply parameters by JSON, provider is called for model but never called for properties of this model. But why? How can I use binders with FromBody?
I've found solution. As it described here [FromBody] behaves differently in comparing to other value providers - it converts complex objects all at once via JsonFormatters. So in addition model binders we should write separate logic just for FromBody. And of course we can catch some points during json processing:
public class MoneyJsonConverter : JsonConverter
{
public override bool CanWrite => false;
public override bool CanConvert(Type objectType)
{
return objectType == typeof(double);
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
string value = (reader.Value ?? "").Replace(" ", "").Replace(",", ".");
TypeConverter converter = TypeDescriptor.GetConverter(modelType);
object result = converter.ConvertFromInvariantString(value);
return result;;
}
}
and using
services.AddMvc(options =>
{
options.ModelBinderProviders.Insert(0, new MoneyModelBinderProvider());
}).AddJsonOptions(options =>
{
options.SerializerSettings.Converters.Add(new MoneyJsonConverter());
});
I have an mvc 4 application.
In one of the post actions I would like to have a parameter of type 'object'.
It should be able to accept from the client an number, a string, and also generic json.
I tried implementing it with the following model binder:
public class ObjectModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (value == null || string.IsNullOrEmpty(value.AttemptedValue))
{
return null;
}
else
{
var js = new JavaScriptSerializer();
var result = js.Deserialize<object>((string)value.ConvertTo(typeof(string)));
return result;
}
}
}
In the client I use jquery ajax to post the data, and also if the value is a javascript object I use JSON.stringify.
When I send a json or an int it works, but if I try sending a string it will throws an exception - "Invalid JSON primitive: THE_STRING_VALUE"
Should I use something else?
Thanks for the help.
The problem is that JSON is a string, so you need to be able to differentiate between strings that are JSON and those that aren't. Try something like this instead:
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (value == null || string.IsNullOrEmpty(value.AttemptedValue))
{
return null;
}
else
{
int n;
string s = (string)value.ConvertTo(typeof(string));
if (s.StartsWith("{") || s.StartsWith("["))
{
var js = new JavaScriptSerializer();
var result = js.Deserialize<object>(s);
return result;
}
else if (int.TryParse(s, out n))
{
return n;
}
return s;
}
}
In my controller, I have an action that takes in 3 arguments (primary_key, property, and value) and uses reflection to set the value on corresponding model. (Found by its primary key)
I thought I could catch the model if it was invlaid with ModelState.IsValid but it evaluates as true. Now it goes to db.SaveChanges(); which throws exception.
The ModelState is valid. (Apparently it is no the model instance as found by the primary key and actually refers to my three inputs).
I thought I could check my model for errors with the following line...
if (System.Data.Entity.Validation.DbEntityValidationResult.ValidationErrors.Empty)
But I am getting a "missing object reference" error.
I have no idea what that means. (New to C# and everything else here.) Any help?
EDIT 1 - SHOW MORE CODE:
Validations
[Column("pilot_disembarked")]
[IsDateAfter(testedPropertyName: "Undocked",
allowEqualDates: true,
ErrorMessage = "End date needs to be after start date")]
public Nullable<System.DateTime> PilotDisembarked { get; set; }
Custom Validatior
public sealed class IsDateAfter : ValidationAttribute, IClientValidatable
{
private readonly string testedPropertyName;
private readonly bool allowEqualDates;
public IsDateAfter(string testedPropertyName, bool allowEqualDates = false)
{
this.testedPropertyName = testedPropertyName;
this.allowEqualDates = allowEqualDates;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var propertyTestedInfo = validationContext.ObjectType.GetProperty(this.testedPropertyName);
if (propertyTestedInfo == null)
{
return new ValidationResult(string.Format("unknown property {0}", this.testedPropertyName));
}
var propertyTestedValue = propertyTestedInfo.GetValue(validationContext.ObjectInstance, null);
if (value == null || !(value is DateTime))
{
return ValidationResult.Success;
}
if (propertyTestedValue == null || !(propertyTestedValue is DateTime))
{
return ValidationResult.Success;
}
// Compare values
if ((DateTime)value >= (DateTime)propertyTestedValue)
{
if (this.allowEqualDates)
{
return ValidationResult.Success;
}
if ((DateTime)value > (DateTime)propertyTestedValue)
{
return ValidationResult.Success;
}
}
return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
}
}
Controller Action
[HttpPost]
public ActionResult JsonEdit(string name, int pk, string value)
{
Voyage voyage = db.Voyages.Find(pk);
var property = voyage.GetType().GetProperty(name);
if (Regex.Match(property.PropertyType.ToString(), "DateTime").Success)
{
try
{
if (Regex.Match(value, #"^\d{4}$").Success)
{
var newValue = DateTime.ParseExact(value, "HHmm", System.Globalization.CultureInfo.InvariantCulture);
property.SetValue(voyage, newValue, null);
}
else if (value.Length == 0)
{
property.SetValue(voyage, null, null);
}
else
{
var newValue = DateTime.ParseExact(value, "yyyy/MM/dd HHmm", System.Globalization.CultureInfo.InvariantCulture);
property.SetValue(voyage, newValue, null);
}
}
catch
{
Response.StatusCode = 400;
return Json("Incorrect Time Entry.");
}
}
else
{
var newValue = Convert.ChangeType(value, property.PropertyType);
property.SetValue(voyage, newValue, null);
}
if (ModelState.IsValid)
{
db.SaveChanges();
Response.StatusCode = 200;
return Json("Success!");
}
else
{
Response.StatusCode = 400;
return Json(ModelState.Keys.SelectMany(key => this.ModelState[key].Errors));
}
}
When any value of your model is null at that time ModelState.IsValid.So first check your model data.
So, I have a search form in my website which posts Search.Value as a parameter where in the project there is a class called Search with a property Value which is an object. When the value is bound by the model binder, object is always System.String[] regardless if I pass in a single number, a single word or anything else.
Can someone explain to me why that is, or if I can make it stop doing that?
Here's the code:
// Search.cs
public sealed class Search {
private object _Value = null;
public object Value {
get {
return this._Value;
}
set {
if (value != null) {
this._Value = value;
this.IsInt32 = (this._Value is Int32);
this.IsString = (this._Value is String);
};
}
}
public bool IsInt32 { get; set; }
public bool IsString { get; set; }
}
// SearchController.cs
[HttpPost]
public ActionResult List(
[Bind(Prefix = "Search", Include = "Value")] Search Search) {
return this.View();
}
// Form HTML
<form action="/Administration/Search" method="post">
#Html.TextBox("Search.Value", Model.Search.Value, new { type = "search", placeholder = "Search", size = 48, required = string.Empty })
<input type="submit" value="⌕" />
</form>
UPDATE
Per #Darin's suggestion and example I've made a custom binder and it seems to be working so far. Here's the code if anyone else runs into this, modify as needed of course:
public class SearchModelBinder : DefaultModelBinder {
public override object BindModel(
ControllerContext ControllerContext,
ModelBindingContext BindingContext) {
if (BindingContext == null) {
throw new ArgumentNullException("BindingContext");
};
ValueProviderResult ValueResult = BindingContext.ValueProvider.GetValue(BindingContext.ModelName + ".Value");
if (ValueResult == null) {
return (null);
};
string Value = ValueResult.AttemptedValue;
if (String.IsNullOrEmpty(Value)) {
return (null);
};
int Int;
if (int.TryParse(Value, out Int)) {
return new Search {
Value = Convert.ToInt32(Value)
};
};
long Long;
if (long.TryParse(Value, out Long)) {
return new Search {
Value = Convert.ToInt64(Value)
};
};
return new Search {
Value = Value
};
}
}
No idea what you are trying to achieve, why are you using object, ... but you could write a custom model binder for this Search model. This model binder will assign directly the parameter that is sent in the request to the Value property:
public class SearchModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName + ".Value");
if (value != null)
{
return new Search
{
Value = value.AttemptedValue
};
}
return base.BindModel(controllerContext, bindingContext);
}
}
which you could register in Global.asax:
ModelBinders.Binders.Add(typeof(Search), new SearchModelBinder());
I have written a Custom Model Binder which is supposed to map Dates, coming from URL-Strings (GET) according to the current culture (a sidenote here: the default model binder does not consider the current culture if you use GET as http-call...).
public class DateTimeModelBinder : IModelBinder
{
#region IModelBinder Members
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
if (controllerContext.HttpContext.Request.HttpMethod == "GET")
{
string theDate = controllerContext.HttpContext.Request.Form[bindingContext.ModelName];
DateTime dt = new DateTime();
bool success = DateTime.TryParse(theDate, System.Globalization.CultureInfo.CurrentUICulture, System.Globalization.DateTimeStyles.None, out dt);
if (success)
{
return dt;
}
else
{
return null;
}
}
return null; // Oooops...
}
#endregion
}
I registered the model binder in global.asax:
ModelBinders.Binders.Add(typeof(DateTime?), new DateTimeModelBinder());
Now the problem occurs in the last return null;. If I use other forms with POST, it would overwrite the already mapped values with null. How can I avoid this?
Thx for any inputs.
sl3dg3
Derive from DefaultModelBinder and then invoke the base method:
public class DateTimeModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
// ... Your code here
return base.BindModel(controllerContext, bindingContext);
}
}
Well, it is actually a trivial solution: I create a new instance of the default binder and pass the task to him:
public class DateTimeModelBinder : IModelBinder
{
#region IModelBinder Members
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
if (controllerContext.HttpContext.Request.HttpMethod == "GET")
{
string theDate = controllerContext.HttpContext.Request.Form[bindingContext.ModelName];
DateTime dt = new DateTime();
bool success = DateTime.TryParse(theDate, System.Globalization.CultureInfo.CurrentUICulture, System.Globalization.DateTimeStyles.None, out dt);
if (success)
{
return dt;
}
else
{
return null;
}
}
DefaultModelBinder binder = new DefaultModelBinder();
return binder.BindModel(controllerContext, bindingContext);
}
#endregion
}
One more possible solution is pass some of the best default model bidners into custom and call it there.
public class BaseApiRequestModelBinder : IModelBinder
{
private readonly IModelBinder _modelBinder;
public BaseApiRequestModelBinder(IModelBinder modelBinder)
{
_modelBinder = modelBinder;
}
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
//calling best default model binder
await _modelBinder.BindModelAsync(bindingContext);
var model = bindingContext.Result.Model as BaseApiRequestModel;
//do anything you want with a model that was bind with default binder
}
}
public class BaseApiRequestModelBinderProvider : IModelBinderProvider
{
private IList<IModelBinderProvider> _modelBinderProviders { get; }
public BaseApiRequestModelBinderProvider(IList<IModelBinderProvider> modelBinderProviders)
{
_modelBinderProviders = modelBinderProviders;
}
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType == typeof(BaseApiRequestModel) || context.Metadata.ModelType.IsSubclassOf(typeof(BaseApiRequestModel)))
{
//Selecting best default model binder. Don't forget to exlude the current one as it is also in list
var defaultBinder = _modelBinderProviders
.Where(x => x.GetType() != this.GetType())
.Select(x => x.GetBinder(context)).FirstOrDefault(x => x != null);
if (defaultBinder != null)
{
return new BaseApiRequestModelBinder(defaultBinder);
}
}
return null;
}
//Register model binder provider in ConfigureServices in startup
services
.AddMvc(options => {
options.ModelBinderProviders.Insert(0, new BaseApiRequestModelBinderProvider(options.ModelBinderProviders));
})