从显式类型的 ASP.NET Core API 控制器(而不是 IActionResult)返回404

NET Core API 控制器通常返回显式类型(如果创建新项目,则默认返回显式类型) ,类似于:

[Route("api/[controller]")]
public class ThingsController : Controller
{
// GET api/things
[HttpGet]
public async Task<IEnumerable<Thing>> GetAsync()
{
//...
}


// GET api/things/5
[HttpGet("{id}")]
public async Task<Thing> GetAsync(int id)
{
Thing thingFromDB = await GetThingFromDBAsync();
if(thingFromDB == null)
return null; // This returns HTTP 204


// Process thingFromDB, blah blah blah
return thing;
}


// POST api/things
[HttpPost]
public void Post([FromBody]Thing thing)
{
//..
}


//... and so on...
}

问题是,return null;-它返回一个 HTTP 204: 成功,没有内容。

这被很多客户端 Javascript 组件认为是成功的,所以有这样的代码:

const response = await fetch('.../api/things/5', {method: 'GET' ...});
if(response.ok)
return await response.json(); // Error, no content!

在线搜索(例如 这个问题这个答案)指向有用的控制器 return NotFound();扩展方法,但是所有这些返回 IActionResult,这与我的 Task<Thing>返回类型不兼容。这个设计模式看起来像这样:

// GET api/things/5
[HttpGet("{id}")]
public async Task<IActionResult> GetAsync(int id)
{
var thingFromDB = await GetThingFromDBAsync();
if (thingFromDB == null)
return NotFound();


// Process thingFromDB, blah blah blah
return Ok(thing);
}

这是可行的,但使用它的返回类型 GetAsync必须改为 Task<IActionResult>-显式类型丢失,并且要么所有的返回类型控制器必须改变(即不使用显式类型)或将有一个混合,一些操作处理显式类型,而其他。此外,单元测试现在需要对序列化做出假设,并明确反序列化 IActionResult的内容,而在此之前,它们有一个具体的类型。

有很多方法可以解决这个问题,但是这似乎是一个容易被设计出来的混杂问题,所以真正的问题是: ASP.NET 核心设计者想要的正确方式是什么?

似乎可能的选择是:

  1. 有一个奇怪的(凌乱的测试)混合显式类型和 IActionResult取决于预期的类型。
  2. 忘记显式类型吧,它们实际上不受 Core MVC 的支持,一直都是使用 IActionResult(在这种情况下,为什么它们会出现呢?)
  3. 编写一个 HttpResponseException的实现,并像使用 ArgumentOutOfRangeException一样使用它(有关实现,请参阅 这个答案)。然而,这确实需要对程序流使用异常,这通常是一个坏主意,也是 MVC 核心团队不赞成
  4. 为 GET 请求编写返回 404HttpNoContentOutputFormatter实现。
  5. 在核心 MVC 应该如何工作的问题上,我还漏掉了什么吗?
  6. 或者,对于一个失败的 GET 请求,为什么 204是正确的,而 404是错误的?

这些都涉及到妥协和重构,这些妥协和重构会丢失一些东西,或者增加一些似乎不必要的复杂性,这些复杂性与 MVC 核心的设计不一致。哪种妥协是正确的? 为什么?

111930 次浏览

You can actually use IActionResult or Task<IActionResult> instead of Thing or Task<Thing> or even Task<IEnumerable<Thing>>. If you have an API that returns JSON then you can simply do the following:

[Route("api/[controller]")]
public class ThingsController : Controller
{
// GET api/things
[HttpGet]
public async Task<IActionResult> GetAsync()
{
}


// GET api/things/5
[HttpGet("{id}")]
public async Task<IActionResult> GetAsync(int id)
{
var thingFromDB = await GetThingFromDBAsync();
if (thingFromDB == null)
return NotFound();


// Process thingFromDB, blah blah blah
return Ok(thing); // This will be JSON by default
}


// POST api/things
[HttpPost]
public void Post([FromBody] Thing thing)
{
}
}

Update

It seems as though the concern is that being explicit in the return of an API is somehow helpful, while it is possible to be explicit it is in fact not very useful. If you're writing unit tests that exercise the request / response pipeline you are typically going to verify the raw return (which would most likely be JSON, i.e.; a string in C#). You could simply take the returned string and convert it back to the strongly typed equivalent for comparisons using Assert.

This seems to be the only shortcoming with using IActionResult or Task<IActionResult>. If you really, really want to be explicit and still want to set the status code there are several ways to do this - but it is frowned upon as the framework already has a built-in mechanism for this, i.e.; using the IActionResult returning method wrappers in the Controller class. You could write some custom middleware to handle this however you'd like, however.

Finally, I would like to point out that if an API call returns null according to W3 a status code of 204 is actually accurate. Why on earth would you want a 404?

204

The server has fulfilled the request but does not need to return an entity-body, and might want to return updated metainformation. The response MAY include new or updated metainformation in the form of entity-headers, which if present SHOULD be associated with the requested variant.

If the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent. This response is primarily intended to allow input for actions to take place without causing a change to the user agent's active document view, although any new or updated metainformation SHOULD be applied to the document currently in the user agent's active view.

The 204 response MUST NOT include a message-body, and thus is always terminated by the first empty line after the header fields.

I think the first sentence of the second paragraph says it best, "If the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent". This is the case with an API. As compared to a 404:

The server has not found anything matching the Request-URI. No indication is given of whether the condition is temporary or permanent. The 410 (Gone) status code SHOULD be used if the server knows, through some internally configurable mechanism, that an old resource is permanently unavailable and has no forwarding address. This status code is commonly used when the server does not wish to reveal exactly why the request has been refused, or when no other response is applicable.

The primary difference being one is more applicable for an API and the other for the document view, i.e.; the page displayed.

In order to accomplish something like that(still, I think that the best approach should be using IActionResult), you can follow, where you can throw an HttpResponseException if your Thing is null:

// GET api/things/5
[HttpGet("{id}")]
public async Task<Thing> GetAsync(int id)
{
Thing thingFromDB = await GetThingFromDBAsync();
if(thingFromDB == null){
throw new HttpResponseException(HttpStatusCode.NotFound); // This returns HTTP 404
}
// Process thingFromDB, blah blah blah
return thing;
}

This is addressed in ASP.NET Core 2.1 with ActionResult<T>:

public ActionResult<Thing> Get(int id) {
Thing thing = GetThingFromDB();


if (thing == null)
return NotFound();


return thing;
}

Or even:

public ActionResult<Thing> Get(int id) =>
GetThingFromDB() ?? NotFound();

I'll update this answer with more detail once I've implemented it.

Original Answer

In ASP.NET Web API 5 there was an HttpResponseException (as pointed out by Hackerman) but it's been removed from Core and there's no middleware to handle it.

I think this change is due to .NET Core - where ASP.NET tries to do everything out of the box, ASP.NET Core only does what you specifically tell it to (which is a big part of why it's so much quicker and portable).

I can't find a an existing library that does this, so I've written it myself. First we need a custom exception to check for:

public class StatusCodeException : Exception
{
public StatusCodeException(HttpStatusCode statusCode)
{
StatusCode = statusCode;
}


public HttpStatusCode StatusCode { get; set; }
}

Then we need a RequestDelegate handler that checks for the new exception and converts it to the HTTP response status code:

public class StatusCodeExceptionHandler
{
private readonly RequestDelegate request;


public StatusCodeExceptionHandler(RequestDelegate pipeline)
{
this.request = pipeline;
}


public Task Invoke(HttpContext context) => this.InvokeAsync(context); // Stops VS from nagging about async method without ...Async suffix.


async Task InvokeAsync(HttpContext context)
{
try
{
await this.request(context);
}
catch (StatusCodeException exception)
{
context.Response.StatusCode = (int)exception.StatusCode;
context.Response.Headers.Clear();
}
}
}

Then we register this middleware in our Startup.Configure:

public class Startup
{
...


public void Configure(IApplicationBuilder app)
{
...
app.UseMiddleware<StatusCodeExceptionHandler>();

Finally actions can throw the HTTP status code exception, while still returning an explicit type that can easily be unit tested without conversion from IActionResult:

public Thing Get(int id) {
Thing thing = GetThingFromDB();


if (thing == null)
throw new StatusCodeException(HttpStatusCode.NotFound);


return thing;
}

This keeps the explicit types for the return values and allows easy distinction between successful empty results (return null;) and an error because something can't be found (I think of it like throwing an ArgumentOutOfRangeException).

While this is a solution to the problem it still doesn't really answer my question - the designers of the Web API build support for explicit types with the expectation that they would be used, added specific handling for return null; so that it would produce a 204 rather than a 200, and then didn't add any way to deal with 404? It seems like a lot of work to add something so basic.

Had another problem with same behavior - all methods return 404. The problem was in missing code block

app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});

I too looked high and low for an answer to what to do about strongly typed responses when I wanted to return an 400 response for bad data sent into the request. My project is in ASP.NET Core Web API (.NET5.0). The solution I found was basically set the status code and return default version of the object. Here is your example with the change to set the status code to 404 and return the default object when the db object is null.

[Route("api/[controller]")]
public class ThingsController : Controller
{
// GET api/things
[HttpGet]
public async Task<IEnumerable<Thing>> GetAsync()
{
//...
}


// GET api/things/5
[HttpGet("{id}")]
public async Task<Thing> GetAsync(int id)
{
Thing thingFromDB = await GetThingFromDBAsync();
if(thingFromDB == null)
{
this.Response.StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status404NotFound;
return default(Thing);
}
        

// Process thingFromDB, blah blah blah
return thing;
}


// POST api/things
[HttpPost]
public void Post([FromBody]Thing thing)
{
//..
}


//... and so on...
}