I have a web api application using asp.net mvc web api that recieve some decimal numbers in viewmodels. I would like to create a custom model binder for decimal type and get it working for all decimals numbers. I have a viewModel like this:
public class ViewModel
{
public decimal Factor { get; set; }
// other properties
}
And the front-end application can send a json with a invalid decimal number like: 457945789654987654897654987.79746579651326549876541326879854
I would like to response with a 400 - Bad Request error and a custom message. I tried create a custom model binder implementing the System.Web.Http.ModelBinding.IModelBinder and registring on the global.asax but does not work. I would like to get it working for all decimals in my code, look what I tried:
public class DecimalValidatorModelBinder : System.Web.Http.ModelBinding.IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
var input = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (input != null && !string.IsNullOrEmpty(input.AttemptedValue))
{
if (bindingContext.ModelType == typeof(decimal))
{
decimal result;
if (!decimal.TryParse(input.AttemptedValue, NumberStyles.Number, Thread.CurrentThread.CurrentCulture, out result))
{
actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.BadRequest, ErrorHelper.GetInternalErrorList("Invalid decimal number"));
return false;
}
}
}
return true; //base.BindModel(controllerContext, bindingContext);
}
}
Adding on the Application_Start:
GlobalConfiguration.Configuration.BindParameter(typeof(decimal), new DecimalValidatorModelBinder());
What can I do?
Thank you.
By default Web API reads a complex type from the request body using a media-type formatter. So it doesn't go through a model binder in this case.
for JSON you can create JsonConverter (in case you are sticking by default with JSON.NET:
public class DoubleConverter : JsonConverter
{
public override bool CanWrite
{
get { return false; }
}
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(double) || objectType == typeof(double?));
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
JToken token = JToken.Load(reader);
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
{
return token.ToObject<double>();
}
if (token.Type == JTokenType.String)
{
// customize this to suit your needs
var wantedSeperator = NumberFormatInfo.CurrentInfo.NumberDecimalSeparator;
var alternateSeparator = wantedSeperator == "," ? "." : ",";
double actualValue;
if (double.TryParse(token.ToString().Replace(alternateSeparator, wantedSeperator), NumberStyles.Any,
CultureInfo.CurrentCulture, out actualValue))
{
return actualValue;
}
else
{
throw new JsonSerializationException("Unexpected token value: " + token.ToString());
}
}
if (token.Type == JTokenType.Null && objectType == typeof(double?))
{
return null;
}
if (token.Type == JTokenType.Boolean)
{
return token.ToObject<bool>() ? 1 : 0;
}
throw new JsonSerializationException("Unexpected token type: " + token.Type.ToString());
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
throw new NotImplementedException("Unnecessary because CanWrite is false. The type will skip the converter.");
}
}
Related
I have a scenario where it ignores the value passed on my query parameter if the value is a string.
Sample Route
v1/data?amount=foo
Here's the sample code
[HttpGet]
public async Task<IActionResult> GetData([FromQuery]decimal? amount)
{
}
So what I have tried so far is, I add a JsonConverter
public class DecimalConverter : JsonConverter
{
public DecimalConverter()
{
}
public override bool CanRead
{
get
{
return false;
}
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
throw new NotImplementedException("Unnecessary because CanRead is false. The type will skip the converter.");
}
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(decimal) || objectType == typeof(decimal?) || objectType == typeof(float) || objectType == typeof(float?) || objectType == typeof(double) || objectType == typeof(double?));
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
value = CleanupDecimal(value);
if (DecimalConverter.IsWholeValue(value))
{
writer.WriteRawValue(JsonConvert.ToString(Convert.ToInt64(value)));
}
else
{
writer.WriteRawValue(JsonConvert.ToString(value));
}
}
private static object CleanupDecimal(object value)
{
if (value is null) return value;
if (value is decimal || value is decimal?)
{
value = decimal.Parse($"{value:G0}");
}
else if (value is float || value is float?)
{
value = float.Parse($"{value:G0}");
}
else if (value is double || value is double?)
{
value = double.Parse($"{value:G0}");
}
return value;
}
private static bool IsWholeValue(object value)
{
if (value is decimal decimalValue)
{
int precision = (decimal.GetBits(decimalValue)[3] >> 16) & 0x000000FF;
return precision == 0;
}
else if (value is float floatValue)
{
return floatValue == Math.Truncate(floatValue);
}
else if (value is double doubleValue)
{
return doubleValue == Math.Truncate(doubleValue);
}
return false;
}
}
So based on my observation, this only works on [FromBody] parameters.
Is there a way to validate query parameters without changing the datatype of it? I know it is possible to just change the datatype from decimal to string and validate it if it is a valid number.
Updated:
I want to have a response like
{
"message": "The given data was invalid.",
"errors": {
"amount": [
"The amount must be a number."
]
}
}
You can use custom model binding:
[HttpGet]
public async Task<IActionResult> GetData([ModelBinder(typeof(CustomBinder))]decimal? amount)
{
}
CustomBinder:
public class CustomBinder:IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
//get amount data with the following code
var data=bindingContext.ValueProvider.GetValue("amount").FirstValue;
//put your logic code of DecimalConverter here
bindingContext.Result = ModelBindingResult.Success(data);
return Task.CompletedTask;
}
}
I would like to format output from DateTime when there is no time part as yyyy-MM-dd i.s.o. yyyy-MM-ddT00:00:00 default in my case.
The controller API is :
[HttpGet("date")]
public async Task<DateTime> GetDateAsync(Date)
{
// dummy code
return DateTime.Now.Date;
}
In Swagger the output is seen as yyyy-MM-ddT00:00:00
I suppose this is due to
internal const string DefaultDateFormatString = #"yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"; in Newtonsoft.Json.JsonSerializerSettings
But I want it to be like yyyy-MM-dd where the time part is not there:
if (value is DateTime d)
{
if (d.TotalSeconds == 0)
{
writer.WriteValue(d.ToString("yyyy-MM-dd"));
}
}
So I would like to know how to customize the serialization under the above condition and then configure that at startup.
This only for data output from the controllers.
I am using ASP.NET 3.1 and using Newtonsoft.Json
Thanks
I created a converter and hooked it up at start-up:
//...
AddNewtonsoftJson(options =>
{
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
options.SerializerSettings.Converters.Add(new JsonDateOnlyConverter());
})
public class JsonDateOnlyConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(DateTime) || objectType == typeof(DateTime?));
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
var t = token.ToString();
if (token.Type == JTokenType.Date )
{
return token.ToObject<DateTime>();
}
if (token.Type == JTokenType.String)
{
if (string.IsNullOrEmpty(t) || string.IsNullOrWhiteSpace(t))
{
return null;
}
if (DateTime.TryParse(t, out var val))
{
return val;
}
}
if (token.Type == JTokenType.Null && (objectType == typeof(DateTime?) || objectType == typeof(DateTime)))
{
return null;
}
throw new JsonSerializationException("Unexpected token type: " + t);
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value is DateTime d)
{
if (d.Date.Ticks == d.Ticks)
{
writer.WriteValue(d.ToString("yyyy-MM-dd"));
}
}
}
}
I have a converter like this
class MultiFormatDateConverter : JsonConverter
{
public List<string> DateTimeFormats { get; set; }
public override bool CanConvert(Type objectType)
{
return objectType == typeof(DateTime);
}
public override bool CanWrite
{
get { return false; }
}
public override void WriteJson(JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer)
{
throw new NotImplementedException();
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)
{
string dateString = (string)reader.Value;
DateTime date;
foreach (string format in DateTimeFormats)
{
// adjust this as necessary to fit your needs
if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out date))
return date;
}
throw new System.Text.Json.JsonException("Unable to parse \"" + dateString + "\" as a date.");
}
}
and here is the configuration
var settings = new JsonSerializerSettings();
settings.DateParseHandling = DateParseHandling.None;
settings.Converters.Add(new MultiFormatDateConverter
{
DateTimeFormats = new List<string> { "yyyyMMddTHHmmssZ", "yyyy-MM-ddTHH:mm","MMMM yyyy","dd/MM/yyyy","dd/MM/yy","MMM-yy","MMM yy"
}
});
and here is how I am calling it:
List<KipReport> rpt730 = JsonConvert.DeserializeObject<List<KipReport>>(responseBody, settings);
This is the JSON and class
[
{
"Name":"Alex",
"MonthWorked":"January 2021",
"LastEdtDate":"16/02/2021",
"LastEditBy":"san"
}
]
class KipReport
{
public string Name { get; set; }
public DateTime? MonthWorked { get; set; }
public DateTime? LastEditDate { get; set; }
}
Mine is a web API and here is the controller which calls the function. Please note it calls the function as Task.Run()
[HttpGet]
public async Task<IActionResult> Get()
{
await Task.Run(()=>_kReport.GetKReports());
return Accepted();
}
When executing it says
16/03/2021 is not a valid date format
Then I used this way for converting than a converter
var settings = new IsoDateTimeConverter { DateTimeFormat = "dd/MM/yyyy" };
Then error is with January 2021 is not a valid date
Does it means, it's not considering the converter??
Since I have a different format for dates I am using a converter.
So for Web API/Task.Run do we need to do anything specific for the Custom converter?
Your properties are of type DateTime? (i.e. nullable value types) so in CanConvert you must check for objectType == typeof(DateTime?) as well as objectType == typeof(DateTime). Then, in Read(), if the incoming objectType is typeof(DateTime?) you should return null in the event of a null JSON token.
The following fixed converter does this and also skips comments:
class MultiFormatDateConverter : JsonConverter
{
public List<string> DateTimeFormats { get; set; } = new ();
public override bool CanConvert(Type objectType) =>
objectType == typeof(DateTime) || objectType == typeof(DateTime?);
public override bool CanWrite => false;
public override void WriteJson(JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer) => throw new NotImplementedException();
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)
{
if (reader.MoveToContent().TokenType == JsonToken.Null)
return objectType == typeof(DateTime?) ? null : throw new System.Text.Json.JsonException("Unable to parse null as a date.");
else if (reader.TokenType != JsonToken.String)
throw new System.Text.Json.JsonException("Unable to parse token \"" + reader.TokenType + "\" as a date.");
string dateString = (string)reader.Value;
foreach (string format in DateTimeFormats)
{
// adjust this as necessary to fit your needs
if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
return date;
}
throw new System.Text.Json.JsonException("Unable to parse \"" + dateString + "\" as a date.");
}
}
public static partial class JsonExtensions
{
public static JsonReader MoveToContent(this JsonReader reader)
{
while ((reader.TokenType == JsonToken.Comment || reader.TokenType == JsonToken.None) && reader.Read())
;
return reader;
}
}
Notes:
In your JSON you have a property named "LastEdtDate" while the corresponding c# property is LastEditDate. The JSON property name is missing the letter i in Edit and so will not get bound to the c# property. I assume this is a typo in the question, but if not, you will need to add [JsonProperty("LastEdtDate")] to LastEditDate.
Demo fiddle here.
Right now I have an attribute called Checkbox. We're using it because of our front end posts "On" and "Off" instead of true/false when a checkbox value is submitted.
Our goal is to parse the on/off values and convert them to true/false before they get to the JSON converter, so they can be picked up as a boolean.
I've considered using this attribute to handle that.
[Checkbox]
[JsonConverter(typeof(InvariantConverter))]
public bool CheckboxInputValue { get; set; }
I need access to the value of the property inside of the checkbox attribute and then need the ability to modify the value.
Open to suggestions and thoughts here.
You cannot store per-instance data in an attribute property since the attribute is created when using the reflection API on the class.
You should rather use a custom JSON converter to convert between the string values and the boolean value.
As Martin Ullrich already suggested you should consider using a dedicated JSON converter.
I left null value handling for you.
public class OnOffStringToBoolConverter : JsonConverter
{
private readonly Type _sourceType = typeof(string);
private readonly Type _targetType = typeof(bool);
public OnOffStringToBoolConverter()
{
}
public override bool CanRead => true;
public override bool CanWrite => true;
public override bool CanConvert(Type objectType)
{
if (objectType == null)
{
throw new ArgumentNullException(nameof(objectType));
}
return objectType == _sourceType;
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader == null)
{
throw new ArgumentNullException(nameof(reader));
}
if (objectType == null)
{
throw new ArgumentNullException(nameof(objectType));
}
if (serializer == null)
{
throw new ArgumentNullException(nameof(serializer));
}
if (reader.Value == null)
{
// Add some null handling logic here if needed.
throw new JsonSerializationException(
$"Unable to deserialize null value to {_targetType.Name}.");
}
if (string.Compare(reader.Value.ToString(), "On", StringComparison.OrdinalIgnoreCase) == 0)
{
return true;
}
if (string.Compare(reader.Value.ToString(), "Off", StringComparison.OrdinalIgnoreCase) == 0)
{
return false;
}
throw new JsonSerializationException(
$"Unable to deserialize '{reader.Value}' to {_targetType.FullName}. " +
$"This converter supports only \"On\", \"Off\" values.");
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (writer == null)
{
throw new ArgumentNullException(nameof(writer));
}
if (serializer == null)
{
throw new ArgumentNullException(nameof(serializer));
}
if (value == null)
{
// Add some null handling logic here if needed.
throw new JsonSerializationException("Unable to serialize null value.");
}
// Write value only if it is boolean type.
if (value is bool boolValue)
{
writer.WriteValue(boolValue ? "On" : "Off");
}
else
{
throw new JsonSerializationException(
$"Unable to serialize '{value}' of type {value.GetType().FullName}. " +
$"This converter supports deserialization of values " +
$"of {_targetType.FullName} type only.");
}
}
}
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());
});