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:
- replace the ProblemDetailsFactory and replace the message in the ModelState or
- 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.
JSON deserialization for type 'WebApi.Dtos.UserRegistrationDto' was missing required properties, including the following: userName, passwordIf I setMaxModelValidationErrors=1and 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