Reputation: 9490
In my ASP.NET MVC Core web application the Json serialization of properties is set to camel case (with first letter lowercase):
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
.AddJsonOptions(opt =>
{
opt.SerializerSettings.ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() };
opt.SerializerSettings.Converters.Add(new StringEnumConverter(true));
});
The serialization to the client is working as expected.
But when the javascript client tries to post data and this data is not valid, he receives a validation message with capital letter properties, this validation messages are the ModelState
:
{"Info":["The Info field is required."]}
Is there a way to make ASP.NET return lowercase property in validation messages of the ModelState
to reflect the naming strategy?
Upvotes: 3
Views: 2853
Reputation: 23
Here is an adapted version of the ToCamelCase()
function of Karel Kral that handles the multi-level names, like "RootObject.SomeProperty.ISODate", which should be converted to "rootObject.someProperty.isoDate".
string? ToCamelCase(string? str)
{
if (string.IsNullOrEmpty(str))
return str;
var hasDot = str.Contains('.');
if (!hasDot && !char.IsUpper(str[0]))
return str;
var chars = str.ToCharArray();
var lastWasDot = false;
for (var i = 0; i < chars.Length; i++)
{
if (i == 1 && !hasDot && !char.IsUpper(chars[i]))
{
break;
}
var hasNext = i + 1 < chars.Length;
if (i > 0 && !lastWasDot && hasNext && !char.IsUpper(chars[i + 1]))
{
var nextDotIndex = NextIndexOfDot(i);
if (nextDotIndex != -1)
{
lastWasDot = true;
i = nextDotIndex;
continue;
}
break;
}
chars[i] = char.ToLowerInvariant(chars[i]);
lastWasDot = false;
}
return new string(chars);
int NextIndexOfDot(int startIndex)
{
for (int i = startIndex; i < chars.Length; i++)
{
if (chars[i] == '.')
return i;
}
return -1;
}
}
Upvotes: 1
Reputation: 21
I have faced the same issue. I have overridden DefaultProblemDetailsFactory.cs from the source code and add logic to change the first letters in the 'errors' dictionary.
Steps:
1 - Create new CustomProblemDetailsFactory.cs class:
internal sealed class CustomProblemDetailsFactory : ProblemDetailsFactory
{
private readonly ApiBehaviorOptions _options;
public CustomProblemDetailsFactory(IOptions<ApiBehaviorOptions> options)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public override ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
statusCode ??= 500;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Type = type,
Detail = detail,
Instance = instance,
};
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
return problemDetails;
}
public override ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
if (modelStateDictionary == null)
{
throw new ArgumentNullException(nameof(modelStateDictionary));
}
statusCode ??= 400;
var problemDetails = new ValidationProblemDetails(modelStateDictionary)
{
Status = statusCode,
Type = type,
Detail = detail,
Instance = instance,
};
if (title != null)
{
// For validation problem details, don't overwrite the default title with null.
problemDetails.Title = title;
}
// FIX LOWERCASE, MAKE THE FIRST LETTERS LOWERCASE
///-----------------------------
if (problemDetails.Errors != null)
{
var newErrors = problemDetails.Errors.ToDictionary(x => this.MakeFirstLetterLowercase(x.Key), x => x.Value);
problemDetails.Errors.Clear();
foreach (var keyValue in newErrors)
{
problemDetails.Errors.Add(keyValue.Key, keyValue.Value);
}
}
///-----------------------------
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
return problemDetails;
}
private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode)
{
problemDetails.Status ??= statusCode;
if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData))
{
problemDetails.Title ??= clientErrorData.Title;
problemDetails.Type ??= clientErrorData.Link;
}
var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
if (traceId != null)
{
problemDetails.Extensions["traceId"] = traceId;
}
}
private string MakeFirstLetterLowercase(string str)
{
if (!string.IsNullOrEmpty(str) && char.IsUpper(str[0]))
{
return str.Length == 1 ? char.ToLower(str[0]).ToString() : char.ToLower(str[0]) + str[1..];
}
return str;
}
}
2 - In the Startup.cs override the default ProblemDetailsFactory:
services.AddSingleton<ProblemDetailsFactory, CustomProblemDetailsFactory>();
After that all keys in the dictionary 'errors' will start with lowercase
Upvotes: 0
Reputation: 5486
There is an easier solution. Use Fluent Validator's ValidatorOptions.Global.PropertyNameResolver
. Taken from here and converted to C# 8 and Fluent Validation 9:
In Startup.cs, ConfigureServices use:
services
.AddControllers()
.SetCompatibilityVersion(CompatibilityVersion.Version_3_0)
.AddFluentValidation(fv =>
{
fv.RegisterValidatorsFromAssemblyContaining<MyValidator>();
// Convert property names to camelCase as Asp.Net Core does https://github.com/FluentValidation/FluentValidation/issues/226
ValidatorOptions.Global.PropertyNameResolver = CamelCasePropertyNameResolver.ResolvePropertyName;
})
.AddNewtonsoftJson(NewtonsoftUtils.SetupNewtonsoftOptionsDefaults);
and resolver itself:
/// <summary>
/// Convert property names to camelCase as Asp.Net Core does
/// https://github.com/FluentValidation/FluentValidation/issues/226
/// </summary>
public class CamelCasePropertyNameResolver
{
public static string? ResolvePropertyName(Type type, MemberInfo memberInfo, LambdaExpression expression)
{
return ToCamelCase(DefaultPropertyNameResolver(type, memberInfo, expression));
}
private static string? DefaultPropertyNameResolver(Type type, MemberInfo memberInfo, LambdaExpression expression)
{
if (expression != null)
{
var chain = PropertyChain.FromExpression(expression);
if (chain.Count > 0)
{
return chain.ToString();
}
}
if (memberInfo != null)
{
return memberInfo.Name;
}
return null;
}
private static string? ToCamelCase(string? s)
{
if (string.IsNullOrEmpty(s) || !char.IsUpper(s[0]))
{
return s;
}
var chars = s.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
if (i == 1 && !char.IsUpper(chars[i]))
{
break;
}
var hasNext = (i + 1 < chars.Length);
if (i > 0 && hasNext && !char.IsUpper(chars[i + 1]))
{
break;
}
chars[i] = char.ToLower(chars[i], CultureInfo.InvariantCulture);
}
return new string(chars);
}
}
Upvotes: 1
Reputation: 9490
The solution is to disable the automatic api validation filter and create own json result with the validation messages:
services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = true;
});
And in the controller:
protected ActionResult ValidationFailed()
{
var errorList = ModelState.ToDictionary(
kvp => kvp.Key.ToCamelCase(),
kvp => kvp.Value.Errors.Select(e => e.ErrorMessage).ToArray()
);
return BadRequest(errorList);
}
public async Task<ActionResult> Create([FromBody]TCreateDto model)
{
if (ModelState.IsValid == false)
{
return ValidationFailed();
}
...
}
The string helper method:
public static string ToCamelCase(this string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}
return name.Substring(0, 1).ToLower() + name.Substring(1);
}
Upvotes: 1