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));
})
Related
I have a modelbinder like this:
public class CustomQuarantineModelBinder : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType.GetInterfaces().Contains(typeof(IQuarantineControl))
{
return new QuarantineModelBinder();
}
return null;
}
}
public class QuarantineModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext modelBindingContext)
{
char[] delimeter = { '|' };
if (modelBindingContext == null)
{
throw new ArgumentNullException(nameof(modelBindingContext));
}
var model = Activator.CreateInstance(modelBindingContext.ModelType);
if (modelBindingContext.ModelType.GetInterfaces().Contains(typeof(IQuarantineControl)))
{
var qc = model as IQuarantineControl;
if (qc != null)
{
var request = modelBindingContext.HttpContext.Request;
string QuarantineControl = request.Form["QuarantineControl"];
if (!string.IsNullOrEmpty(QuarantineControl))
{
string[] components = QuarantineControl.Split(delimeter);
qc.QuarantineClear();
qc.QuarantineControlID = Convert.ToInt32(components[0]);
qc.QuarantineState = (QuarantineState)Convert.ToInt32(components[1]);
for (int i = 2; i < components.Length; i++)
{
qc.QuarantineReasons.Add(components[i]);
}
}
}
}
modelBindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
However, other fields in the model is getting turned as null or empty. I would like to set QuarantineState, QuarantineControlId etc.. without affecting other values. Thanks
When your custom IModelBinderProvider returns a binder, that binder is responsible for binding the entire type. If you wish to fall back to the default MVC binder for other properties, you will need to do so explicitly.
Perhaps something like;
public class CustomQuarantineModelBinder : IModelBinderProvider
{
private readonly IModelBinderProvider baseProvider;
public CustomQuarantineModelBinder(IModelBinderProvider baseProvider){
this.baseProvider = baseProvider;
}
public IModelBinder GetBinder(ModelBinderProviderContext context){
...
return new QuarantineModelBinder(BaseProvider.GetBinder(context));
}
}
public class QuarantineModelBinder : IModelBinder
{
private readonly IModelBinder binder;
public QuarantineModelBinder(IModelBinder binder){
this.binder = binder;
}
public Task BindModelAsync(ModelBindingContext modelBindingContext)
{
...
binder.BindModelAsync(modelBindingContext);
...
}
}
services.AddMvc(options =>
{
var baseProvider = options.ModelBinderProviders
.OfType<ComplexObjectModelBinderProvider>()
.First();
options.ModelBinderProviders.Insert(0, new CustomQuarantineModelBinder(baseProvider));
});
When the model object is posted the string values that are empty are converted to null. This is the default behavior of the MVC model binder.
You can try a workaround like this,
public sealed class EmptyStringModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext,
ModelBindingContext bindingContext)
{
bindingContext.ModelMetadata.ConvertEmptyStringToNull = false;
//Binders = new ModelBinderDictionary() { DefaultBinder = this };
return base.BindModel(controllerContext, bindingContext);
}
}
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'm trying to grab the "status" and "all" key, value from the requested URL, and can't figure out how to build my class object.
The JSON API specification I'm referring to can be found here:
http://jsonapi.org/recommendations/#filtering
// requested url
/api/endpoint?filter[status]=all
// my attempt at model binding
public class FilterParams
{
public Dictionary<string, string> Filter { get; set; }
}
[HttpGet]
public string Get([FromUri] FilterParams filter)
{
// never gets populated...
var filterStatus = filter.Filter["status"];
}
If you're building json:api apps on .Net Core, I strongly recommend checking out this library: https://github.com/json-api-dotnet/JsonApiDotNetCore
It handles all of the heavy lifting for you and for this specific example, (you need to get the filter value) the solution looks like:
public FooController : JsonApiController<Foo> {
private readonly IQueryAccessor _queryAccessor;
public FooController(IQueryAccessor queryAccessor, /* ... */)
: base(/* ... */) {
_queryAccessor = queryAccessor;
}
[HttpGet]
public override async Task<IActionResult> GetAsync() {
var status = _queryAccessor.GetRequired<string>("status");
// ...
}
}
You could use IModelBinder for that:
Define a model binder:
public class FilterParamsModelBinder : IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
if (bindingContext.ModelType != typeof(FilterParams)) return false;
Dictionary<string, string> result = new Dictionary<string, string>();
var parameters = actionContext.Request.RequestUri.Query.Substring(1);
if(parameters.Length == 0) return false;
var regex = new Regex(#"filter\[(?<key>[\w]+)\]=(?<value>[\w^,]+)");
parameters
.Split('&')
.ToList()
.ForEach(_ =>
{
var groups = regex.Match(_).Groups;
if(groups.Count == 0)
bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Cannot convert value.");
result.Add(groups["key"].Value, groups["value"].Value);
});
bindingContext.Model = new FilterParams { Filter = result};
return bindingContext.ModelState.IsValid;
}
}
Use it:
[HttpGet]
public string Get([ModelBinderAttribute(typeof(FilterParamsModelBinder))] FilterParams filter)
{
//your code
}
If you could define a route like "/api/endpoint?filter=status,all" instead, than you could use a TypeConverter for that:
Define a converter:
public class FilterConverter : TypeConverter
{
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (!(value is string)) return base.ConvertFrom(context, culture, value);
var keyValue = ((string)value).Split(',');
return new FilterParams
{
Filter = new Dictionary<string, string> { [keyValue[0]] = keyValue[1] }
};
}
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
}
Use it:
[TypeConverter(typeof(FilterConverter))]
public class FilterParams
{
public Dictionary<string, string> Filter { get; set; }
}
[HttpGet]
public string Get(FilterParams filter)
{
var filterStatus = filter.Filter["status"];
}
In the RC1 I use the following code for abstract classes or interfaces binding:
public class MessageModelBinder : IModelBinder {
public Task<ModelBindingResult> BindModelAsync(ModelBindingContext bindingContext) {
if(bindingContext.ModelType == typeof(ICommand)) {
var msgTypeResult = bindingContext.ValueProvider.GetValue("messageType");
if(msgTypeResult == ValueProviderResult.None) {
return ModelBindingResult.FailedAsync(bindingContext.ModelName);
}
var type = Assembly.GetAssembly(typeof(MessageModelBinder )).GetTypes().SingleOrDefault(t => t.FullName == msgTypeResult.FirstValue);
if(type == null) {
return ModelBindingResult.FailedAsync(bindingContext.ModelName);
}
var metadataProvider = (IModelMetadataProvider)bindingContext.OperationBindingContext.HttpContext.RequestServices.GetService(typeof(IModelMetadataProvider));
bindingContext.ModelMetadata = metadataProvider.GetMetadataForType(type);
}
return ModelBindingResult.NoResultAsync;
}
}
This binder only reads model type (messageType parameter) from query string and overrides metadata type. And the rest of the work performed by standard binders such as BodyModelBinder.
In Startup.cs I just add first binder:
services.AddMvc().Services.Configure<MvcOptions>(options => {
options.ModelBinders.Insert(0, new MessageModelBinder());
});
Controller:
[Route("api/[controller]")]
public class MessageController : Controller {
[HttpPost("{messageType}")]
public ActionResult Post(string messageType, [FromBody]ICommand message) {
}
}
How can I perform this in RC2?
As far as I understand, now I have to use IModelBinderProvider. OK, I tried.
Startup.cs:
services.AddMvc().Services.Configure<MvcOptions>(options => {
options.ModelBinderProviders.Insert(0, new MessageModelBinderProvider());
});
ModelBinderProvider:
public class MessageModelBinderProvider : IModelBinderProvider {
public IModelBinder GetBinder(ModelBinderProviderContext context) {
if(context == null) {
throw new ArgumentNullException(nameof(context));
}
return context.Metadata.ModelType == typeof(ICommand) ? new MessageModelBinder() : null;
}
}
ModelBinder:
public class MessageModelBinder : IModelBinder {
public Task BindModelAsync(ModelBindingContext bindingContext) {
if(bindingContext.ModelType == typeof(ICommand)) {
var msgTypeResult = bindingContext.ValueProvider.GetValue("messageType");
if(msgTypeResult == ValueProviderResult.None) {
bindingContext.Result = ModelBindingResult.Failed(bindingContext.ModelName);
return Task.FromResult(0);
}
var type = typeof(MessageModelBinder).GetTypeInfo().Assembly.GetTypes().SingleOrDefault(t => t.FullName == msgTypeResult.FirstValue);
if(type == null) {
bindingContext.Result = ModelBindingResult.Failed(bindingContext.ModelName);
return Task.FromResult(0);
}
var metadataProvider = (IModelMetadataProvider)bindingContext.OperationBindingContext.HttpContext.RequestServices.GetService(typeof(IModelMetadataProvider));
bindingContext.ModelMetadata = metadataProvider.GetMetadataForType(type);
bindingContext.Result = ModelBindingResult.Success(bindingContext.ModelName, Activator.CreateInstance(type));
}
return Task.FromResult(0);
}
}
But I cannot specify NoResult. If I do not specify bindingContext.Result, I get null model in controller.
If I specify bindingContext.Result, I get empty model without setting model fields.
I had a similar requirement with custom model binding and abstract classes and the suggestions posted by dougbu on github AspNet/Mvc/issues/4703 worked for me. I upgraded from RC1 to ASP.NET Core 1.0 and needed to modify my custom model binder with his recommendations. I've copy & pasted his code below in case the link to the github issue breaks. Read the comments in the github issue for security considerations around code that creates objects of a requested type on the server.
MessageModelBinderProvider
public class MessageModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType != typeof(ICommand))
{
return null;
}
var binders = new Dictionary<string, IModelBinder>();
foreach (var type in typeof(MessageModelBinderProvider).GetTypeInfo().Assembly.GetTypes())
{
var typeInfo = type.GetTypeInfo();
if (typeInfo.IsAbstract || typeInfo.IsNested)
{
continue;
}
if (!(typeInfo.IsClass && typeInfo.IsPublic))
{
continue;
}
if (!typeof(ICommand).IsAssignableFrom(type))
{
continue;
}
var metadata = context.MetadataProvider.GetMetadataForType(type);
var binder = context.CreateBinder(metadata);
binders.Add(type.FullName, binder);
}
return new MessageModelBinder(context.MetadataProvider, binders);
}
}
MessageModelBinder
public class MessageModelBinder : IModelBinder
{
private readonly IModelMetadataProvider _metadataProvider;
private readonly Dictionary<string, IModelBinder> _binders;
public MessageModelBinder(IModelMetadataProvider metadataProvider, Dictionary<string, IModelBinder> binders)
{
_metadataProvider = metadataProvider;
_binders = binders;
}
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
var messageTypeModelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, "messageType");
var messageTypeResult = bindingContext.ValueProvider.GetValue(messageTypeModelName);
if (messageTypeResult == ValueProviderResult.None)
{
bindingContext.Result = ModelBindingResult.Failed();
return;
}
IModelBinder binder;
if (!_binders.TryGetValue(messageTypeResult.FirstValue, out binder))
{
bindingContext.Result = ModelBindingResult.Failed();
return;
}
// Now know the type exists in the assembly.
var type = Type.GetType(messageTypeResult.FirstValue);
var metadata = _metadataProvider.GetMetadataForType(type);
ModelBindingResult result;
using (bindingContext.EnterNestedScope(metadata, bindingContext.FieldName, bindingContext.ModelName, model: null))
{
await binder.BindModelAsync(bindingContext);
result = bindingContext.Result;
}
bindingContext.Result = result;
}
}
I have two custom binders to get DateTime and DateTime? to bind.
I'm registering them in the Global.asax like:
public class MvcApplication : HttpApplication
{
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
ModelBinders.Binders[typeof (DateTime)] = new DateTimeModelBinder();
//ModelBinders.Binders[typeof (DateTime?)] = new NullableDateTimeModelBinder();
//ModelBinders.Binders.Add(typeof (DateTime), new DateTimeModelBinder());
//ModelBinders.Binders.Add(typeof(DateTime?), new NullableDateTimeModelBinder());
}
}
I've also tried it like this:
ModelBinders.Binders.Add(typeof (DateTime), new DateTimeModelBinder());
ModelBinders.Binders.Add(typeof(DateTime?), new NullableDateTimeModelBinder());
However, they are never being hit.
The model binding classes are here (stolen from the Internet):
public class DateTimeModelBinder : IModelBinder
{
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
DateTime dateTime;
var isDate = DateTime.TryParse(value.AttemptedValue,
Thread.CurrentThread.CurrentUICulture,
DateTimeStyles.None,
out dateTime);
if (!isDate)
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName,
$"Cannot bind value {value.AttemptedValue} to a DateTime");
return DateTime.UtcNow;
}
return dateTime;
}
}
and here:
public class NullableDateTimeModelBinder : IModelBinder
{
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (string.IsNullOrWhiteSpace(value.AttemptedValue))
{
return null;
}
DateTime dateTime;
var isDate = DateTime.TryParse(value.AttemptedValue, Thread.CurrentThread.CurrentUICulture, DateTimeStyles.None, out dateTime);
if (!isDate)
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"Cannot convert {value.AttemptedValue} to a DateTime.");
return DateTime.UtcNow;
}
return dateTime;
}
}
What as I missing?