1

My development environment is.Net7 WebApi (out of the box)

Below is the relevant code. DataAnnotations I have implemented localization.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Dtos
{
    public class UserRegistrationDto
    {
        [Required(ErrorMessage = "UserNameRequiredError")]
        [MinLength(6, ErrorMessage = "UserNameMinLengthError")]
        [MaxLength(30, ErrorMessage = "UserNameMaxLengthError")]
        public required string UserName { get; set; }

        [Required(ErrorMessage = "PasswordRequiredError")]
        public required string Password { get; set; }
    }
}
[HttpPost]
public async Task<IActionResult> RegisterUser([FromBody] UserRegistrationDto userRegistration)
{
    return Ok(1);
    // IdentityResult userResult = await _userManager.CreateAsync(new IdentityUser { UserName = userRegistration.UserName }, userRegistration.Password);

    // return userResult.Succeeded ? StatusCode(201) : BadRequest(userResult);
}

When the request body is invalid JSON.

curl -X 'POST' \
  'https://localhost:7177/Authenticate/RegisterUser' \
  -H 'accept: */*' \
  -H 'Api-Version: 1.0' \
  -H 'Content-Type: application/json' \
  -d '{}'
{
    "$": [
        "JSON deserialization for type 'WebApi.Dtos.UserRegistrationDto' was missing required properties, including the following: userName, password"
    ],
    "userRegistration": [
        "The userRegistration field is required."
    ]
}

When the request body is Empty.

curl -X 'POST' \
  'https://localhost:7177/Authenticate/RegisterUser' \
  -H 'accept: */*' \
  -H 'Api-Version: 1.0' \
  -H 'Content-Type: application/json' \
  -d ''
{
    "": [
        "The userRegistration field is required."
    ]
}

It throws exception information before binding to DTO, can this exception information be localized? If not, is it possible to capture this information for secondary processing, such as returning a fixed JSON format?

I've tried this in the Program.cs entry file, but it's not ideal.

.ConfigureApiBehaviorOptions(options =>
{
    options.SuppressModelStateInvalidFilter = false;
    options.InvalidModelStateResponseFactory = context =>
    {
        bool knownExceptions = context.ModelState.Values.SelectMany(x => x.Errors).Where(x => x.Exception is JsonException || (x.Exception is null && String.IsNullOrWhiteSpace(x.ErrorMessage) == false)).Count() > 0;
        if (knownExceptions)
        {
            return new BadRequestObjectResult(new { state = false, message = localizer["InvalidParameterError"].Value });
        }
        // ...
        return new BadRequestObjectResult(context.ModelState);
    };
})

I have also tried this method, but I can’t capture the exception information that failed when binding DTO alone. They will appear together with the ErrorMessage exception information in DTO like the above writing method.

.AddControllers(options =>
{
    // options.Filters.Add(...);
    // options.ModelBindingMessageProvider // This doesn't work either, it seems to support [FromForm]
})

Back to the topic, can it be localized? Or there is something wrong with the code. I just learned .Net not long ago. Most of the information I learned came from search engines and official documents. Thanks in advance.

6
  • If you wanna localize error message, You can refer to this issue Commented Dec 19, 2022 at 8:22
  • @XinranShen I tried ModelBindingMessageProvider, it has no effect. Commented Dec 19, 2022 at 15:15
  • But in generally, You can use This provider to localize error message, Check this Blog, Is it what you want? It also use the link i provided before. Commented Dec 20, 2022 at 8:18
  • @XinranShen Yes, I just tried it and it does work. But can the first JSON serialization error be localized? JSON deserialization for type 'WebApi.Dtos.UserRegistrationDto' was missing required properties, including the following: userName, password If I set MaxModelValidationErrors=1 and pass invalid parameters, it always returns the first error. But it doesn't matter much to me right now. I can set the DTO model to be empty in the method of the controller, for example [FromBody] UserRegistrationDto? userRegistration Commented Dec 20, 2022 at 9:14
  • 1
    @XinranShen Thank you. I have read these articles before posting the question. Initially, I wanted to unify the format of these exception messages. From my current understanding of .Net, it seems that the exception of JSON deserialization can only be verified on the client side for the time being. I still need to continue to study later, maybe there will be a better way, I plan to close this question, thank you again. Commented Dec 21, 2022 at 4:15

3 Answers 3

2

I also found myself in the same position, trying to localize all error messages in my API, so i dug deeper.

The "JSON deserialization for type '[Type]' was missing required properties, including the following: [Properties]" message results from a JsonException that is thrown inside the package System.Text.Json during deserialization (ThrowHelper.Serialization.ThrowJsonException_JsonRequiredPropertyMissing(..) ).

Unfortunately, the source of this message is hard-coded and cannot be changed. Also, the classes/structs using this ThrowHelper method (ReadStackFrame, ObjectDefaultConverter, ...) are mostly internal and cannot be replaced using dependency injection, so there's no way to affect the creation of this exception (except by writing your own JSON deserialization).

The exection eventually gets handled inside Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter, method ReadRequestBodyAsync, where it is added to the ModelState. The ModelState is later processed in the DefaultProblemDetailsFactory.

So, the best thing you can do is:

  1. replace the ProblemDetailsFactory and replace the message in the ModelState or
  2. replace the InputFormatter and handle the JsonException on your own.

For the 1st variant, we rebuild the DefaultProblemDetailsFactory and add it to dependency injection:

CustomProblemDetailsFactory.cs:

/// <inheritdoc />
public sealed class CustomProblemDetailsFactory : ProblemDetailsFactory
{
    private readonly ApiBehaviorOptions _options;
    private readonly Action<ProblemDetailsContext>? _configure;

    public CustomProblemDetailsFactory(
        IOptions<ApiBehaviorOptions> options,
        IOptions<ProblemDetailsOptions>? problemDetailsOptions = null)
    {
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _configure = problemDetailsOptions?.Value?.CustomizeProblemDetails;
    }
    
    /// <inheritdoc />
    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;
    }

    /// <inheritdoc />
    public override ValidationProblemDetails CreateValidationProblemDetails(
        HttpContext httpContext,
        ModelStateDictionary modelStateDictionary,
        int? statusCode = null,
        string? title = null,
        string? type = null,
        string? detail = null,
        string? instance = null)
    {
        ArgumentNullException.ThrowIfNull(modelStateDictionary);

        statusCode ??= 400;

        if (modelStateDictionary.ContainsKey("$"))
        {
            modelStateDictionary.Remove("$");
            modelStateDictionary.TryAddModelError("$", "My error message");
        }

        var problemDetails = new ValidationProblemDetails(modelStateDictionary)
        {
            Title = Resources.ErrorMessages.ValidationProblemTitle,
            Status = statusCode,
            Type = type,
            Detail = detail,
            Instance = instance,
        };

        if (title != null)
        {
            problemDetails.Title = title;
        }

        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;
        }

        _configure?.Invoke(new() { HttpContext = httpContext!, ProblemDetails = problemDetails });
    }
}

and in your Statup/Program.cs:

services.AddTransient<ProblemDetailsFactory, CustomProblemDetailsFactory>();
services.AddProblemDetails();

The replacement of the error message happens in these lines:

        if (modelStateDictionary.ContainsKey("$"))
        {
            modelStateDictionary.Remove("$");
            modelStateDictionary.TryAddModelError("$", "My error message");
        }

For the 2nd variant, we can replace the SystemTextJsonInputFormatter by our own one and handle the exception ourselves.

MyJsonInputFormatter.cs:

public class MyJsonInputFormatter : TextInputFormatter
{
    private readonly SystemTextJsonInputFormatter _baseFormatter;

    public MyJsonInputFormatter(SystemTextJsonInputFormatter baseFormatter)
    {
        _baseFormatter = baseFormatter;

        foreach (var mediaType in _baseFormatter.SupportedMediaTypes)
        {
            SupportedMediaTypes.Add(mediaType);
        }

        foreach (var encoding in _baseFormatter.SupportedEncodings)
        {
            SupportedEncodings.Add(encoding);
        }
    }

    public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
    {
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(encoding);

        var httpContext = context.HttpContext;

        // convert request body stream to a MemoryStream, so we can reuse it later
        MemoryStream inputStreamCopy = new MemoryStream();
        await httpContext.Request.Body.CopyToAsync(inputStreamCopy);
        httpContext.Request.Body = inputStreamCopy;

        var (inputStream, usesTranscodingStream) = GetInputStream(httpContext, encoding);
        inputStreamCopy.Seek(0, SeekOrigin.Begin);

        try
        {
            await JsonSerializer.DeserializeAsync(inputStream, context.ModelType, _baseFormatter.SerializerOptions);
        }
        catch (JsonException jsonException)
        {
            var path = jsonException.Path ?? string.Empty;

            var modelStateException = WrapExceptionForModelState(jsonException);

            context.ModelState.TryAddModelError(path, modelStateException, context.Metadata);

            return InputFormatterResult.Failure();
        }

        inputStreamCopy.Seek(0, SeekOrigin.Begin);
        return await _baseFormatter.ReadRequestBodyAsync(context, encoding);
    }

    public override IReadOnlyList<string>? GetSupportedContentTypes(string contentType, Type objectType)
    {
        return _baseFormatter.GetSupportedContentTypes(contentType, objectType);
    }

    private Exception WrapExceptionForModelState(JsonException jsonException)
    {
        string message = "My error message";
        return new InputFormatterException(message, jsonException);
    }
    
    private static (Stream inputStream, bool usesTranscodingStream) GetInputStream(HttpContext httpContext, Encoding encoding)
    {
        if (encoding.CodePage == Encoding.UTF8.CodePage)
        {
            return (httpContext.Request.Body, false);
        }

        var inputStream = Encoding.CreateTranscodingStream(httpContext.Request.Body, encoding, Encoding.UTF8, leaveOpen: true);
        return (inputStream, true);
    }
}

In Startup/Program.cs, we search for the original SystemTextJsonInputFormatter and replace it by our own:

services.AddControllers(options =>
    {
        int index;
        for (index = 0; index < options.InputFormatters.Count; index++)
        {
            if (options.InputFormatters[index] is SystemTextJsonInputFormatter)
            {
                break;
            }
        }

        if (index >= options.InputFormatters.Count)
        {
            throw new ArgumentException($"{nameof(SystemTextJsonInputFormatter)} not found");
        }

        var originalFormatter = options.InputFormatters[index] as SystemTextJsonInputFormatter;
        options.InputFormatters[index] = new MyJsonInputFormatter(originalFormatter!);
    })

In both cases, you can replace the error message by a generic one, but unfortunately, it's not possible to retrieve the original parameters (like the problematic fields names or the cause of the error) other than by parsing them from the original error message.

A disadvantage of the 2nd variant is that in the case of a valid model, the model will be deserialized twice.

Sign up to request clarification or add additional context in comments.

Comments

0

Use the following method.

.AddControllers(options =>
{
    // options.ModelBindingMessageProvider.Set...
})

It seems that the exception of JSON deserialization caused by passing invalid parameters can only be eliminated on the client side. So far it seems I haven't found a localization for this exception, but it's not very important to me at the moment. Thanks @XinranShen for helping point me in the right direction.

Comments

0

In addition to above answer

without using CustomProblemDetailsFactory, you can continue to use ProblemDetailsFactory

builder.Services
    .AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var factory = context.HttpContext.RequestServices.GetRequiredService<ProblemDetailsFactory>();
            var problemDetails = factory.CreateValidationProblemDetails(
                context.HttpContext,
                context.ModelState,
                StatusCodes.Status400BadRequest,
                "One or more validation errors occurred.");

            if (context.ModelState.ContainsKey("$"))
            {
                // This is a special case for when the request body (denoted by "$") is invalid.
                // If we don't handle this case, the default error message will be "The JSON value could not be converted to SomeType."
                problemDetails.Errors["$"] = ["The request format is invalid"];
            }

            return new BadRequestObjectResult(problemDetails);
        };
    });

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.