Skip to content

Should Minimal API's return ProblemDetails by default instead of an empty response body for error types? #60394

@sander1095

Description

@sander1095

Is there an existing issue for this?

  • I have searched the existing issues

Is your feature request related to a problem? Please describe the problem.

I am trying to replace usage of Controllers with Minimal API. My goal is to create the same API with the same error handling and OpenAPI metadata as I would with Controllers.

However, Minimal API's don't behave the same way as controllers do when it comes to returning ProblemDetails (and enriching the corresponding OpenAPI response content type) when errors like 400, 404, 500, etc.. are returned.

Minimal API's return an empty response body by default when using (Typed)Results.NotFound/BadRequest/Etc.., whereas controllers will automatically return a ProblemDetails instance with the corresponding error code (NotFound(),BadRequest(), etc..).
Controllers will even enrich the OpenAPI document with ProblemDetails when you specify [ProducesResponseType(StatusCodes.Status404NotFound)]; you don't even need to set the ProblemDetails type!

This means that you need to write a lot more code in a Minimal API to achieve consistent API error reporting. I'd like this be to be fixed by changing the way Minimal API's generate errors so they include ProblemDetails instances by default for each error.

Let's take a look at a reproducable example for controllers and minimal API. This is done with ASP.NET Core 9 and Microsoft.AspNetCore.OpenApi version 9.0.1. I've removed unnecessary parts of the OpenAPI documents to keep the issue easier to read.

Click here to see the Controllers approach

Code

[HttpGet("{id:int:min(1)}", Name = "Talks_GetTalk")]
[ProducesResponseType<TalkModel>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<CreateTalkModel> GetTalks(int id)
{
    var talk = SampleTalks.Talks.FirstOrDefault(x => x.Id == id);
    if (talk == null)
    {
        return NotFound();
    }
    return Ok(talk);
}

OpenAPI document

{
  "paths": {
    "/api/talks/{id}": {
      "get": {
        "tags": [
          "Talks"
        ],
        "operationId": "Talks_GetTalk",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "minimum": 1,
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/TalkModel"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TalkModel"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/TalkModel"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    }
  }
}

As you can see, the OpenAPI document specifies the ProblemDetails response body for the 404 not found error, without any extra code required in the controller. I consider this a great default!

Click here to see the Minimal API approach

Code

var api = app.MapGroup("api/talks");
api.MapGet("/{id:int:min(1)}", GetTalk).WithName("Talks_GetTalk");

public static Results<Ok<TalkModel>, NotFound> GetTalk(int id)
{
    var talk = SampleTalks.Talks.FirstOrDefault(x => x.Id == id);
    return talk == null ?
        TypedResults.NotFound() :
        TypedResults.Ok(talk);
}

OpenAPI document

{
  "paths": {
    "/api/talks/{id}": {
      "get": {
        "tags": [
          "TalkEndpoints"
        ],
        "operationId": "Talks_GetTalk",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "minimum": 1,
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TalkModel"
                }
              }
            }
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    }
  }
}

Creating feature-parity between Controllers and Minimal API

As you can see above, the minimal API does not contain ProblemDetails with its out-of-the-box approach.

In order to get ProblemDetails to work for the NotFound error, I would need to write the following code:

// The ProblemHttpResult is not present in the OpenAPI document, as explained in https://github.com/dotnet/aspnetcore/issues/58719
// To ensure that it is added, we can use a combination of TypedResults and extension methods (which is not ideal).
// We could also fall back to using Results and use extension methods for the OpenAPI metadata, but we'd lose compile-time safety..
api.MapGet("/{id:int:min(1)}", GetTalk)
    .WithName("Talks_GetTalk")
    .ProducesProblem(StatusCodes.Status404NotFound);

public static Results<Ok<TalkModel>, ProblemHttpResult> GetTalk(int id)
{
    // This still excludes the traceid from the ProblemDetails instance for brevity purposes
    var talk = SampleTalks.Talks.FirstOrDefault(x => x.Id == id);
    return talk == null ?
        TypedResults.Problem(title: "Not Found", statusCode: StatusCodes.Status404NotFound) :
        TypedResults.Ok(talk);
}

This will result in the OpenAPI document containing these responses for the operation:

"responses": {
  "200": {
    "description": "OK",
    "content": {
      "application/json": {
        "schema": {
          "$ref": "#/components/schemas/TalkModel"
        }
      }
    }
  },
  "404": {
    "description": "Not Found",
    "content": {
      "application/problem+json": {
        "schema": {
          "$ref": "#/components/schemas/ProblemDetails"
        }
      }
    }
  }
}

This is not 100% equal to the controllers, but close enough to get the same API behavior.

Alternatively, perhaps I could write some middleware (and/or adjust the way the OpenAPI document is generated) to get these things to work, instead of having to modify each endpoint. But I believe there would be downsides around code clarity and performance.

To summarize, my problem with the Minimal API approach is that it takes too much custom code to get the same consistent API error behaviour that controllers give you. I understand there's a delicate balance between Minimal API's and controllers; Minimal API's are minimal for a reason. However, I hope the team and community can understand and agree with the need for a default, consistent API surface when creating ASP.NET Core minimal apps when it comes to errors.

Describe the solution you'd like

The out-of-the-box behaviour from controllers works well to create a consistent API surface. Because every error (for existing endpoints) will be turned into a ProblemDetails (by default), handwritten or generated API clients will always be able to deserialize into ProblemDetails, making it easier for clients to perform error handling.

I'd like the same behaviour in Minimal API's.

This would also matter for the (AFAIK) upcoming request validation in Minimal API's. I can't find a related issue, so I'll assume it works the same as with controllers. where I'd like the OpenAPI document to contain a ValidationProblemDetails without me having to manually perform validation, set up a (validation) problem details instance, and decorate the endpoint with this new return value.

So, in summary, I think allowing the following code to generate the following OpenAPI document would be a great productivity boost, reduce code clutter and create more consistent API surfaces (when calling endpoints that exists).

An example:

Code

var api = app.MapGroup("api/talks");
api.MapGet("/{id:int:min(1)}", GetTalk).WithName("Talks_GetTalk");

public static Results<Ok<TalkModel>, NotFound> GetTalk(int id)
{
    var talk = SampleTalks.Talks.FirstOrDefault(x => x.Id == id);
    return talk == null ?
        TypedResults.NotFound() :
        TypedResults.Ok(talk);
}

OpenAPI document

"responses": {
  "200": {
    "description": "OK",
    "content": {
      "application/json": {
        "schema": {
          "$ref": "#/components/schemas/TalkModel"
        }
      }
    }
  },
  "404": {
    "description": "Not Found",
    "content": {
      "application/problem+json": {
        "schema": {
          "$ref": "#/components/schemas/ProblemDetails"
        }
      }
    }
  }
}

Additional context

Related issues

I've found an issue that is a little bit related: #59560 , which in turn might be related to #58719 . One of the comments from @mikekistler says:

I think this approach may not work well with the generation of OpenAPI documents that was added in .NET 9. In particular, I think it would require ProducesResponseType attributes to be specified in order to get the error responses to be included in the generated OpenAPI document. This is because TypedResults.Problem does not provide metadata for the OpenAPI document, unlike other TypedResults methods like TypedResults.BadRequest. Is that important to you?

This is important to me. If I currently want a consistent API surface where each error returns ProblemDetails, I would need to use TypedResults.Problem. However, if this doesn't add metadata about this response to the the OpenAPI document, setting up OpenAPI metadata becomes more painful for Minimal API compared to the current approach with controllers which is explained above.

Further context

The discussion started in #60337 . There's not a lot of context there, but I thought it'd be worth adding.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-openapi

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions