Convert null-valued results to 404 in ASP.NET Core MVC

· 1353 words · 7 minutes to read

ASP.NET Core MVC is pretty flexible in terms of how it expects you to return results from the API actions. You could return an IActionResult, which gives you certain control over the status code and the nature of the response (i.e. object or a file or a status code only and so on). You could return a concrete object instance, and the framework will serialize it to the relevant response format. Finally, you could also return the new ActionResult which allows you to mix both of the previous approaches in a single method, giving you the best of both worlds.

Let’s have a look at what happens in the framework when you return a null object instance and how you can change that behavior.

The default treatment of null objects 🔗

It just keeps coming up. I keep getting asked about it, and sooner or later it gets raised on most projects - what happens when you return a null object instance from an API built with ASP.NET Core MVC? And even more so, how can we change the default framework behavior? In fact, it keeps coming back as an issue in the ASP.NET Core MVC repository too.

For illustration purposes, let’s imagine we have an action that looks up an item in some database. As mentioned before, we could actually write it in three different ways, so let’s do that:

// IActionResult approach

[HttpGet("{id}")]

public IActionResult GetById(int id)

{

    var item = _repository.GetById(id);

    return Ok(item);

}



// ActionResult<T&gt; approach

[HttpGet("{id}")]

public ActionResult<Item> GetById(int id)

{

    var item = _repository.GetById(id);

    return item;

}



// specific type approach

[HttpGet("{id}")]

public Item GetById(int id)

{

    var item = _repository.GetById(id);

    return item;

}

Now what if you call these endpoints with an ID that results in a null being returned from the repository? Turns out, in all 3 cases, out-of-the-box behavior of MVC is to produce a 204 No Content response.

Why is that? As opposed to its predecessors (ASP.NET MVC, ASP.NET Web API…), ASP.NET Core MVC actually tries to be pretty smart when dealing with nulls here. In all of the above cases, the framework will try to select an output formatter to handle the response object. This would normally be a JSON formatter, an XML formatter or any other media type relevant formatter. But for nulls, the framework has a special formatter registered in the pipeline, called HttpNoContentOutputFormatter, which converts null responses from your actions into a 204 No Content responses.

This means that our null return would never end up being serialized into JSON or XML or any other formatter that could have been selected - which may or may not be something that you want. Additionally, this HttpNoContentOutputFormatter would also kick in if you return void or Task (or - in other words - if you return nothing) from an action.

You can disable this null value handling on the HttpNoContentOutputFormatter itself by setting its TreatNullValueAsNoContent property to false, as shown next.

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc(o =>

    {

        o.OutputFormatters.RemoveType(typeof(HttpNoContentOutputFormatter));

        o.OutputFormatters.Insert(0, new HttpNoContentOutputFormatter 

        { 

            TreatNullValueAsNoContent = false;

        });

    });

}

This however, only disables the 204 behavior - which, again, might or might not be what you want. The result is a null return would now end up being a 200 OK response with a null written to the response payload.

Depending on your clients, and how they deal with data, you may rather want to expose a 404 in those cases instead - and to me that would make most sense, wouldn’t it?

404 NotFound instead of 204 NoContent 🔗

It’s very easy to change this behavior by introducing a custom filter.

With just a one or two lines of code, you are easily able to convert from ObjectResult (which is the IActionResult type that the framework will always use, in all of our 3 discussed cases, even if you return a concrete type from the action, instead of IActionResult) to NotFoundResult which will produce a 404 Not Found.

This could be an action filter or a result filter, as long as the code runs before the result gets a chance to execute. The result filter approach is likely better, especially combined with some pre-set order value, because that would ensure no other filter can change our changed result.

public class NotFoundActionFilterAttribute : ActionFilterAttribute

{

    public override void OnActionExecuted(ActionExecutedContext context)

    {

        if (context.Result is ObjectResult objectResult && objectResult.Value == null)

        {

            context.Result = new NotFoundResult();

        }

    }

}



public class NotFoundResultFilterAttribute : ResultFilterAttribute

{

    public override void OnResultExecuting(ResultExecutingContext context)

    {

        if (context.Result is ObjectResult objectResult && objectResult.Value == null)

        {

            context.Result = new NotFoundResult();

        }

    }

}

Then it’s just a matter of registering it properly. Just like with any filter, you can do it on a controller level…

[Route("api/items")]

[ApiController]

[NotFoundResultFilter]

public class ItemsController : ControllerBase

{

    //omitted for brevity

}

…action level…

[HttpGet("{id}")]

[NotFoundResultFilter]

public ActionResult<Item> GetById(int id)

{

    var item = _repository.GetById(id);

    return item;

}

… or globally:

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc(o =>

    {

        o.Filters.Add(new NotFoundResultFilterAttribute());

    });

}

As a more esoteric solution, it is also possible to run an action filter as part of the controller:

[Route("api/items")]

[ApiController]

[NotFoundResultFilter]

public class ValuesController : Controller

{

    //omitted for brevity

    public override void OnActionExecuted(ActionExecutedContext context)

    {

        if (context.Result is ObjectResult objectResult && objectResult.Value == null)

        {

            context.Result = new NotFoundResult();

        }

    }

}

Finally, you can also use the application model convention mechanism, to selectively apply the filter where it makes sense to you - using some predefined convention.

For example, instead of a sort of “brute force” global registration like we did before, where the filter really applies to everything, you can choose to use a global registration via a convention, that only “lights up” for controllers decorated with [ApiController] attribute (or any other convention that makes you happy).

Personally I find that approach the most elegant.

    public class NotFoundResultFilterConvention : IControllerModelConvention

    {

        public void Apply(ControllerModel controller)

        {

            if (IsApiController(controller))

            {

                controller.Filters.Add(new NotFoundResultFilterAttribute());

            }

        }



        private static bool IsApiController(ControllerModel controller)

        {

            // [ApiController] implements IApiBehaviorMetadata

            if (controller.Attributes.OfType<IApiBehaviorMetadata>().Any())

            {

                return true;

            }



            return controller.ControllerType.Assembly.GetCustomAttributes().OfType<IApiBehaviorMetadata>().Any();

        }

    }

And then the convention just needs to be registered:

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc(o =>

    {

        o.Conventions.Add(new NotFoundResultFilterConvention());

        //o.Filters.Add(new NotFoundResultFilterAttribute());

    });

}

IAlwaysRunResultFilter 🔗

We are almost there, but our design still has one flaw. What if some piece of code short circuits an action response and sets an ObjectResult with a null on its own?

This is definitely possible, especially if you use various third party libraries or components as part of your execution pipeline. In those cases our filters - whether we used an action filter, or result filter, would not be executed. Or, in other words, they would run only if the controller/action execution was uninterrupted.

To demonstrate how this would work, let’s imagine a silly short circuiting controller.

public class ShortCircuitingFilterAttribute : ActionFilterAttribute

{

    public override void OnActionExecuting(ActionExecutingContext context)

    {

        context.Result = new ObjectResult(null);

    }

}

With such short circuiting filter in place as part of the pipeline, none of our previous configurations - whether it was a simple attribute annotations or the elaborate set up with application model convention would work anymore, and the 204 No Content would “leak through”.

To address that deficiency, ASP.NET Core 2.1 introduced a new feature (not really documented at the moment I think) called IAlwaysRunResultFilter. It allows you to register a result filter that will run regardless of whether the IActionResult produced as part of request handling was produced by action or short circuited by something else.

So, we can address the problem if we re-write our previous filter to IAlwaysRunResultFilter:

public class NotFoundAlwaysRunFilterAttribute : Attribute, IAlwaysRunResultFilter

{

    public void OnResultExecuted(ResultExecutedContext context)

    {

    }



    public void OnResultExecuting(ResultExecutingContext context)

    {

        if (context.Result is ObjectResult objectResult && objectResult.Value == null)

        {

            context.Result = new NotFoundResult();

        }

    }

}

Using that variant of result filter, instead of the earlier implementation - regardless of the registration method chosen (any of the previously mentioned would work, except the embedding inside of a controller cause it’s not an action filter) - will gives us 100% certainty the action result conversion code will run.

Hope this was helpful!

About


Hi! I'm Filip W., a software architect from Zürich 🇨🇭. I like Toronto Maple Leafs 🇨🇦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁢󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP