Reputation: 3149
I am creating an ASP.Net 5 application with MVC 6, using .Net 4.5.1. I have a POST method that uses a FromBody parameter to get the object automatically.
[HttpPost]
public IActionResult Insert([FromBody]Agent agent)
{
try
{
var id = service.Insert(agent);
return Ok(id);
}
catch (Exception ex)
{
return HttpBadRequest(ex);
}
}
This is just a proof a concept, I won't return only the id on success or the full exception on error.
When a valid JSON is sent everything works fine. However when an invalid JSON is sent, I get an exception during debug:
Exception thrown: 'Newtonsoft.Json.JsonReaderException' in Newtonsoft.Json.dll
Additional information: After parsing a value an unexpected character was encountered: l. Path 'Name', line 2, position 20.
The problem is that after this error, the method is called normally, but with a null parameter, and the exception doesn't propagate.
I could check for null and return a generic message, but that is not as useful as the original message, which has important information, such as the tag name and position of the invalid character.
So I want to capture this exception and return it to the HTTP caller. How do I do that? Something like this:
{"error": "After parsing a value an unexpected character was encountered: l. Path 'Name', line 2, position 20"}
I know I could capture and deserialize the JSON manually inside a try/catch block, but that is not acceptable for me. I want to do it and continue using FromBody, which I find very productive.
Upvotes: 9
Views: 5288
Reputation: 65692
For .Net 6 using System.Text.Json (not Newtonsoft) when invalid JSON is passed, eg a large number in a Integer field, we get this trippy exception:
"title": "One or more validation errors occurred". "status: 400, "errors": [ "The dot field is required."]I tried the ActionFilterAttribute
and it didn't work; it doesn't get invoked. I even tried ApiExceptionFilterAttribute : ExceptionFilterAttribute
and while its Constructor is called before the error; the OnException()
method isn't invoked.
With little choice except to roll my own I made a custom [FromBody]
Attribute we can use in our Controllers, replace [FromBody]
with [ModelBinder(typeof(JsonModelBinder))]
:
ControllerMethod([ModelBinder(typeof(JsonModelBinder))] Dto dto)
The catch (JsonException ex)
will only return one invalid property regardless if there's more, it doesn't support multiple. You have to fix them up one at a time. IMHO no invalid JSON should ever be sent, so while we cater for it there's no need to invest time on Garbage In, Garbage Out. If numeric values have their bounds enforced, dates are passed as strings and booleans as true/false then invalid JSON will never be a problem.
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.ModelBinding;
// NOTE: When invalid JSON is passed the ASP.Net [FromBody] Attribute returns an ugly message,
// this JsonBinder allows us to intercept the input and return a more user friendly message.
public class JsonModelBinder : IModelBinder
{
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
// Get the request body stream
var request = bindingContext.HttpContext.Request;
using var reader = new StreamReader(request.Body);
// Read the request body as a string
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
try
{
// Deserialize the request body as a JSON object
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
};
// This line will cause an Exception if the JSON is invalid.
var model = JsonSerializer.Deserialize(content, bindingContext.ModelType, options);
// Validate the deserialized object
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(model!);
if (!Validator.TryValidateObject(model!, validationContext, validationResults, true))
{
// If validation fails, add error messages to the ModelState
foreach (var validationResult in validationResults)
{
bindingContext.ModelState.AddModelError(validationResult.MemberNames.First(), validationResult.ErrorMessage ?? string.Empty);
}
}
// Set the deserialized object as the model for the binding context
bindingContext.Result = ModelBindingResult.Success(model);
}
catch (JsonException ex)
{
// Rethrow the JsonException, this will trigger the ExceptionHandlerMiddleware's Try/Catch and we want
// to show this inner exceptions detail indicating the first invalid property, instead of "the DTO failed to deserialize.".
throw ex;
// Or if there isn't an outer Try/Catch you can return the error:
var error = new JsonValidationError
{
PropertyName = "myProperty",
ErrorMessage = ex.Message
};
var result = new BadRequestObjectResult(error);
bindingContext.Result = ModelBindingResult.Success(result);
}
}
Upvotes: 0
Reputation: 1999
I found myself with exactly the same problem, but was able to find a different solution. I will share my solution here as an alternative to @ypsilo0n's answer.
Instead of checking in every controller the if (!ModelState.IsValid)
we can have this middleware filter:
public class FooFilter : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var modelState = context.ModelState;
if (modelState != null && modelState.IsValid == false)
{
// this class takes the model state and parses
// it into a dictionary with all the errors
var errorModel = new SerializableError(modelState);
context.Result = new BadRequestObjectResult(errorModel);
}
}
}
Now, the controller never gets called because this middleware runs before and ends the request. (read docs for more information).
When we set a non-null context.Result
it means "end the HTTP request here" (the docs) -- not very user friendly/intuitive if you ask me but OK (would expect a return value instead).
using .net core 1.1
Upvotes: 7
Reputation: 596
The default JsonInputFormatter
will in fact return a null model upon encountering an error - but it will populate ModelState
with all exceptions.
So you have access to all encountered errors by digging into ModelState
:
[HttpPost]
public IActionResult Insert([FromBody]Agent agent)
{
if (!ModelState.IsValid)
{
var errors = ModelState
.SelectMany(x => x.Value.Errors, (y, z) => z.Exception.Message);
return BadRequest(errors);
}
// Model is valid, do stuff.
}
Output of above is an array of all exception messages, e.g.:
[
"After parsing a value an unexpected character was encountered: l. Path 'Name', line 2, position 20",
"Another exception message..."
]
Upvotes: 13