Martin Staufcik
Martin Staufcik

Reputation: 9490

ASP.NET MVC validation return lowercase property name

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

Answers (4)

maftieu
maftieu

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

Mikhail  Babich
Mikhail Babich

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

Karel Kral
Karel Kral

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

Martin Staufcik
Martin Staufcik

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

Related Questions