Wednesday, April 13, 2022

Returning HTTP 204 (No Content) from .NET Minimal API

 I recently converted some ASP.NET web api projects from using controllers to using minimal apis. And I ran into a weirdness. If you return "null" from a controller method, then the response is HTTP 204 (No Content), but if you return "null" from a minimal api, the response is HTTP 200 (OK) with the string "null" as the body.

The short version:
To return HTTP 204 from a minimal API method, use "Results.NoContent()" as your return value.
This also means that if you want to return actual content, you will need to wrap that in something like "Results.Json(your_content)".

If you're interested, read on for my experience. I'm sure that I'll get "Jeremy, you're doing it wrong" responses, but that does not invalidate my learning path which may help someone else.

I'll start out by saying that web api is not my area of expertise. I primarily use it so that I can spin up test services to get fake data for my applications.

Controller Behavior

For my api, there is an endpoint that allows you to get an individual "Person" item by specifying an ID. 

Sample endpoint:
    http://localhost:9874/people/2

Sample output:

    {"id":2,"givenName":"Dylan","familyName":"Hunt","startDate":"2000-10-02T00:00:00-07:00","rating":8,"formatString":""}


This comes from a controller method that looks like this:
    [HttpGet("{id}")]
    public async Task<Person?> GetPerson(int id)
    {
        return await _provider.GetPerson(id);
    }

The return value from this method is a nullable Person. If the ID is found, then the Person is returned, otherwise, "null" is fine.

However, the result to the caller is not "null", instead it is an HTTP 204 (No Content). This is appropriate, and we can see some Fiddler results that show this.

Valid Record
Fiddler "Raw" result with call to "http://localhost:9874/people/2":
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 13 Apr 2022 14:26:50 GMT
Server: Kestrel
Content-Length: 117

{"id":2,"givenName":"Dylan","familyName":"Hunt","startDate":"2000-10-02T00:00:00-07:00","rating":8,"formatString":""}
This shows the HTTP 200 (OK) with the JSON results at the bottom.

Invalid Record
Fiddler result with call to "http://localhost:9874/people/20":
HTTP/1.1 204 No Content
Content-Length: 0
Date: Wed, 13 Apr 2022 14:27:00 GMT
Server: Kestrel
This shows that if we use id=20 (which does not exist), then we get the HTTP 204 (No Content) that we expect for this api.

Minimal API Default Template

Since this service had only a few endpoints (3), I converted them to use minimal apis in .NET 6. To be honest, I did not do a lot of research before attempting this. I primarily looked at the default template for the minimal api project and went from there.

I used the following command to generate the project from the template:
    dotnet new webapi -minimal --no-https

The sample endpoint has weather forecast data:
  app.MapGet("/weatherforecast", () =>
  {
      var forecast =  Enumerable.Range(1, 5).Select(index =>
          new WeatherForecast
          (
              DateTime.Now.AddDays(index),
              Random.Shared.Next(-20, 55),
              summaries[Random.Shared.Next(summaries.Length)]
          ))
          .ToArray();
      return forecast;
  })
  .WithName("GetWeatherForecast");
This shows a "/weatherforecast" endpoint that does not take parameters, and returns the resulting object (an array of "WeatherForecast") directly.

My First Pass

On my first pass at this, I attempted something similar. Here's the endpoint that I created:
    app.MapGet("/people/{id}", async (int id, IPeopleProvider provider) => 
    {
        return await provider.GetPerson(id);
    })
    .WithName("GetPerson");
This is a bit more complex since the endpoint has a parameter, and the method has some dependency injection.

The basics of the lambda expression parameters: the first parameter ("id") is mapped to the "{id}" parameter of the endpoint. The "provider" parameter comes from the dependency injection container. The controller version also uses dependency injection (although the "IPeopleProvider" dependency is mapped through the constructor rather than the method).

The content of the minimal api method is exactly the same as the content of the controller method (the return type is "Task<Person?>" in both cases).

However, the behavior is different.

Let's look at some output.

Valid Record
Fiddler "Raw" result with call to "http://localhost:9874/people/2":
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 13 Apr 2022 14:27:46 GMT
Server: Kestrel
Content-Length: 117

{"id":2,"givenName":"Dylan","familyName":"Hunt","startDate":"2000-10-02T00:00:00-07:00","rating":8,"formatString":""}
This is the same result that we had with the controller method: HTTP 200 (OK) with the JSON results at the bottom.

Invalid Record
Fiddler result with call to "http://localhost:9874/people/20":
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 13 Apr 2022 14:27:49 GMT
Server: Kestrel
Content-Length: 4

null
This is where things get strange. The result is HTTP 200 (OK) and the body of the response is "null" -- the string "null".

This looks even stranger when you run this in a browser. 

Web browser open tab with the string "null" shown as the output.


This is not the behavior that I want (and it actually broke some code that was looking for the HTTP 204 status code).

Returning HTTP 204 (No Content)

I did a little bit of searching and found some other folks who were getting the behavior. In the end I found I needed to take more control over the response from the api method.

Here's the method that I ended up with:
    app.MapGet("/people/{id}", async (int id, IPeopleProvider provider) => 
    {
        var person = await provider.GetPerson(id);
        return person switch
        {
            null => Results.NoContent(),
            _ => Results.Json(person)
        };
    })
    .WithName("GetPerson");
This is a bit different. In this case, I get the "person" back from the "GetPerson" method on the provider. This will either be populated or null (as we saw before).

Then if the "person" variable is null, I explicitly return "Results.NoContent()".

If the "person" variable is not null, then I take the value and wrap it in "Results.Json()".

Note: We cannot just return the "person" variable like we did earlier because we need the signature of the lambda expression to be consistent. Since the "null" path returns a "Results", we need the happy path to also return a "Results".

This gets us back to where we were before:

Valid Record
Fiddler "Raw" result with call to "http://localhost:9874/people/2":
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 13 Apr 2022 15:27:32 GMT
Server: Kestrel
Content-Length: 117

{"id":2,"givenName":"Dylan","familyName":"Hunt","startDate":"2000-10-02T00:00:00-07:00","rating":8,"formatString":""}
This shows the HTTP 200 (OK) with the JSON results at the bottom.

Invalid Record
Fiddler result with call to "http://localhost:9874/people/20":
HTTP/1.1 204 No Content
Date: Wed, 13 Apr 2022 15:27:34 GMT
Server: Kestrel
This shows that if we use ID=20 (which does not exist), then we get the HTTP 204 (No Content) that we expect for this api. (Yay!)

Why I Wrote This

The reason I wrote this article is two-fold. The first is that if I have run into an issue, there are probably other folks who have run into it as well. More info is good.

The second is that I'm a bit frustrated with the messaging that comes from Microsoft. I should be used to it by now. I have been seeing for years how "easy" something is in the demo. But when it comes to building something a bit more real, things get harder.

In particular, the default template is a bit misleading. Yes, it will work for a lot of scenarios, but since the behavior here is specifically different from using controllers (which many people will be converting from), it would be really nice to have a "this is different" marker somewhere.

The other thing is about "minimal api" tutorial (Tutorial: Create a minimal web API with ASP.NET Core). Even though it does show using "Results" in the code, the only instruction is "copy this code into your project". There is no explanation of the code or why you might need it. (In my opinion, it's not much of a tutorial if it doesn't explain why you are doing something.)

Anyway, I hope that this will be helpful to someone out there. Feel free to share your thoughts.

Wrap Up

I seem to be having quite a few "who moved my cheese?" moments as the .NET framework and C# language continue to evolve. I really don't mind my cheese being somewhere else, but I would *really* appreciate a sign that says:
"Your cheese is no longer here. Here's where you can find it."
Happy Coding!

No comments:

Post a Comment