Why is a different strategy from MVC required?
The controllers for MVC and API actions are now the same in .Net Core due to the combination of Web API and MVC. Nevertheless, in terms of error handling, you should most likely take a different approach for API errors, even though they may seem similar.
Returning an error page to the browser is the appropriate course of action for MVC actions, which are normally carried out in response to user input in the browser. This is typically not the case with an API.
Most frequently, back-end or javascript code makes API calls; in both scenarios, you never want to just show the API’s response. Rather, we determine whether our action was successful by examining the status code and parsing the response, providing the user with any necessary data. In these cases, an error page is not useful. Because JSON (or XML) is expected instead of HTML, it makes client code difficult and bloats the response with HTML.
The methods for handling errors are not all that different from MVC, even though we want to return data in a different format for Web API actions. It is essentially the same flow most of the time, but we return JSON rather than a View. Let’s examine a few instances.
The minimal approach
In a professional application, it is unacceptable to not display a friendly error page when using MVC actions. Although it’s not ideal, many invalid request types are much more acceptable when using an API with empty response bodies. If an API route doesn’t exist, just returning a 404 status code (without a response body) might give the client enough information to update their code.
This is what ASP.NET Core provides us out of the box, requiring no configuration.
This might be sufficient for many common status codes, depending on your requirements, but it is rarely enough for validation failures. Returning a 400 Bad Request to a client that sends you invalid data won’t be helpful enough for the client to figure out what’s wrong. We should, at the very least, inform them of the fields that are incorrect, and for every failure, we should ideally return an informative message.
ASP.NET Web API makes this easy. Assuming we are using model binding, we can use IValidatableObject and/or data annotations to obtain validation for free. It only takes one simple line of code to return the validation results to the client in JSON format.
This is the model we used:
public class GetProductRequest : IValidatableObject
{
[Required]
public string ProductId { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (...)
{
yield return new ValidationResult("ProductId is invalid", new[] { "ProductId" });
}
}
}
And our controller action:
[HttpGet("product")]
public IActionResult GetProduct(GetProductRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
...
}
When the ProductId is absent, the response body in JSON looks like this and the status code is 400.
{
"ProductId":["The ProductId field is required."]
}
This offers the barest minimum for a customer to use our service, but it is easy to raise the bar and give a far better customer experience. We’ll examine how easy it is to advance our service in the upcoming sections.
Providing more details in response to particular errors
It is simple to include more details if we determine that a status code-only strategy is too basic. It is strongly advised to do this. In many cases, a status code is insufficient to identify the root cause of a failure. In isolation, a 404 status code, for instance, could indicate:
- The request is being sent to the incorrect website entirely (possibly the ‘www’ site instead of the ‘api’ subdomain).
- While the domain is correct, the URL does not correspond to a route.
- Although the resource is not available, the URL maps to a route correctly.
It could be very helpful for a client if we could offer information that would help differentiate between these cases. This is our initial effort to address the final one of these:
[HttpGet("product")]
public async Task<IActionResult> GetProduct(GetProductRequest request)
{
...
var model = await _db.Get(...);
if (model == null)
{
return NotFound("Product not found");
}
return Ok(model);
}
Although the message we are returning now is more helpful, it is still far from ideal. The primary issue is that the framework will return a plain text response instead of JSON when a string is used in the NotFound method.
A consistent JSON service is much easier to deal with as a client than one that returns a different content type for specific errors.
The code shown below can be quickly changed to fix this issue, but we’ll discuss a better solution in the next section.
return NotFound(new { message = "Product not found" });
Customising the response structure for consistency
Creating anonymous objects on the fly is not the way to go about providing a consistent experience for your clients. Even in situations where the request is denied, our API ought to provide the same response structure.
Let us create a basic class called ApiResponse:
public class ApiResponse
{
public int StatusCode { get; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Message { get; }
public ApiResponse(int statusCode, string message = null)
{
StatusCode = statusCode;
Message = message ?? GetDefaultMessageForStatusCode(statusCode);
}
private static string GetDefaultMessageForStatusCode(int statusCode)
{
switch (statusCode)
{
...
case 404:
return "Resource not found";
case 500:
return "An unhandled error occurred";
default:
return null;
}
}
}
Additionally, in order for us to return data, we’ll need a derived ApiOkResponse class:
public class ApiOkResponse : ApiResponse
{
public object Result { get; }
public ApiOkResponse(object result)
:base(200)
{
Result = result;
}
}
To handle validation errors, let’s finally declare an ApiBadRequestResponse class (we will need to replace the built-in functionality used above if we want our responses to be consistent).
public class ApiBadRequestResponse : ApiResponse
{
public IEnumerable<string> Errors { get; }
public ApiBadRequestResponse(ModelStateDictionary modelState)
: base(400)
{
if (modelState.IsValid)
{
throw new ArgumentException("ModelState must be invalid", nameof(modelState));
}
Errors = modelState.SelectMany(x => x.Value.Errors)
.Select(x => x.ErrorMessage).ToArray();
}
}
Although these classes are very basic, they can be tailored to meet your specific needs.
Our action becomes: if we modify it to make use of these ApiResponse based classes.
[HttpGet("product")]
public async Task<IActionResult> GetProduct(GetProductRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(new ApiBadRequestResponse(ModelState));
}
var model = await _db.Get(...);
if (model == null)
{
return NotFound(new ApiResponse(404, $"Product not found with id {request.ProductId}"));
}
return Ok(new ApiOkResponse(model));
}
The code has become slightly more complex, but the general structure remains the same for all three of our action’s response types—success, bad request, and not found.
Centralising Validation Logic
Refactoring this generic code into an action filter makes sense because you perform validation in almost every action. This improves consistency, minimizes the size of our actions, and gets rid of redundant code.
public class ApiValidationFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(new ApiBadRequestResponse(context.ModelState));
}
base.OnActionExecuting(context);
}
}
Handling global errors
The most effective way for us to give our client specific error information is for our controller actions to react to incorrect input. On occasion, though, we must address more general problems. As instances of this, consider:
- A 401 Unauthorized code that the security middleware returned.
- A 404 is returned by a request URL that is not mapped to a controller action.
- Worldwide deviations. Try catch blocks should not be used to clutter your actions unless there is a specific exception for which you can take action.
As with MVC, using StatusCodePagesWithReExecute and UseExceptionHandler is the simplest way to handle global errors.
As we previously discussed, inner middleware (like an API action) can return a non-success status code. In this case, the middleware enables you to execute a custom response in response to the status code and handle it further.
In a similar manner, UseExceptionHandler records and captures unhandled exceptions, enabling you to take an additional action to address the problem. In this instance, we set up both middleware components to point to the same action.
The middleware is added to startup.cs:
app.UseStatusCodePagesWithReExecute("/error/{0}");
app.UseExceptionHandler("/error/500");
...
//register other middleware that might return a non-success status code
Next, we include our error-handling procedure:
[Route("error/{code}")]
public IActionResult Error(int code)
{
return new ObjectResult(new ApiResponse(code));
}
With this in place, our error action—which returns our typical ApiResponse—will handle all exceptions and non-success status codes (without a response body).
Custom Middleware
You can use your own custom middleware to supplement or replace the built-in middleware for the utmost in control. The example below returns our basic ApiResponse object as JSON and handles any bodyless response. When combined with the code that returns ApiResponse objects from our actions, we can make sure that all requests produce a consistent JSON body along with a status code, and that success and failure responses have the same common structure:
public class ErrorWrappingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ErrorWrappingMiddleware> _logger;
public ErrorWrappingMiddleware(RequestDelegate next, ILogger<ErrorWrappingMiddleware> logger)
{
_next = next;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Invoke(HttpContext context)
{
try
{
await _next.Invoke(context);
}
catch(Exception ex)
{
_logger.LogError(EventIds.GlobalException, ex, ex.Message);
context.Response.StatusCode = 500;
}
if (!context.Response.HasStarted)
{
context.Response.ContentType = "application/json";
var response = new ApiResponse(context.Response.StatusCode);
var json = JsonConvert.SerializeObject(response);
await context.Response.WriteAsync(json);
}
}
}
Conclusion
MVC error code handling and ASP.NET Core API error handling are distinct but similar. Instead of returning custom views, we want to return custom objects (serialized as JSON) at the action level.
The StatusCodePagesWithReExecute middleware can still be used for generic errors; however, our code must be changed to return an ObjectResult rather than a ViewResult.
It’s easy to write your own middleware to handle errors precisely as needed if you want complete control.
Javier is Content Specialist and also .NET developer. He writes helpful guides and articles, assist with other marketing and .NET community work