DEV Community

nausaf
nausaf

Posted on

Aborted attempt to return meaningful Problem Details response from model binding errors in an ASP.NET Core Minimal API

Model Binding is the process of binding parameters of the route handler to request parameters (from route segments, query string parameters, cookies, request headers or request body) or services in the DI container.

The automatically added EndpointMiddleware, which is the last middleware in the request pipeline, performs model binding, then invokes the handler for the requested route.

If an error occurs during model binding, then a Problem Details response is NOT returned by the EndpointMiddleware. I have verified the following behaviour:

If a model binding error occurred but NOT due to an issue with contents of the HTTP request, then, in any ASP.NET Core environment, the EndpointMiddleware would return a 500 status code.

An example is when a service that was meant to be resolved from the DI container and passed in as an argument to the handler could not be resolved. No exception would be throw, only 500 would be set as the status code of HttpContext.Request

If the error occurred due to an issue with contents of the request, e.g. a required field or value is missing or a request parameter has an invalid/malformed value or a value of an incorrect data type, then

  • In Development environment (and possibly in any non-Production environment), a BadHttpRequestException is thrown by the middleware. This has:

    • StatusCode property set to 400
    • a Message property that is informative such as:

      Failed to read parameter "CreateProductArgs createProductArgs" from the request body as JSON.

    • InnerException property which, if there was problem deserializing the JSON request body, would be System.Text.Json.JsonException.

      Again, this has an informative Message property:

      JSON deserialization for type 
      'problemdetailstestapi.CreateProductArgs' was 
      missing required properties, 
      including the following: price
      
  • In Production Environment, the EndpointMiddleware sets status code 400 in the outgoing response with a blank response body.

    However we can enable throwing of BadHttpRequestException in Production environment by adding the following line in Program.cs before builder.Build() is called (from this issue in dotnet/aspnetcpore repo) to get our hands on this information.

    Now, even in Production, if a model binding error occurred due to an issue with contents of the request, then a BadHttpRequestException would be thrown by EndpointMiddleware. This is useful as the exception can be quite informative:

    builder.Services.Configure<RouteHandlerOptions>(
      options => { options.ThrowOnBadRequest = true; }
    );
    

Clearly, the messages above in BadHttpRequestException are useful but cannot be sent back to the client as doing so would reveal internal execution and implementation details.

I also do not want to parse them to extract information as for the same exception, the structure of the error message can be different in different sitautions. Also, exception messages may change in the future.

However, the messages are very useful for logging, at Information level or above, and that alone is a good reason to turn on throwing of BadHttpRequestException in Production environment (i.e. to catch and log the exception).

I have verified that an error does not gets logged from within EndpointMiddleware when there is a model binding exception binding some request to handelr parameters at level Information or above. So we do need to catch and log this exception ourselves.

Based on the above behaviour, I believe we can create and return informative Problem Details responses in the event of a model binding error, as follows:

  • Enable throwing HttpBadRequestException in Production environment.

  • Add a middleware just before RoutingMiddleware. This would catch catch a BadHttpRequestException.

  • If a BadHttpRequestException exception is caught in the middleware that was thrown by the RoutingMiddleware rather than by an endpoint filter or by the invoked router handler:

    • If InnerException is System.Text.Json.JsonException then, since by default only a single request body is invalid in the specific sense that either a provided value is of an incorrect type, or a required value is missing. Report this with a ProblemDetails response.
    • Otherwise the issue with some other part of the request. Examples include the following: a requried route segment is missing or is of an incorrect value or the JSON body is missing. Report this with a ProblemDetails response.
  • If a BadHttpRequestException was NOT caught in the middleware that was thrown by the EndpointMiddleware, i.e. no exception was caught, or an exception was caught but it was not BadHtpRequestException or a BadHttpRequestException was caught but it hadn’t been thrown by the EndpointMiddleware (i.e. had ben thrown by an endpoint filter or from somewhere in the invoked route handler), then we do not have the information to create a meaningful ProblemDetails response.

    So we just let it - the reponse or the exception - propagate up the request pipeline.

The trouble is, for all of this logic, that is not difficult to implement but is a a bit convoluted, all I get is the ability to distinguish between two classes of errors in the request that between each other cover pretty much anything that could go wrong with the request (headers, route segments, query string, request body, cookies).

I implemented a middleware to do this (see below) and it was not pretty, and it didn't include the check for the stack trace to see if the BadHttpRequestException had been thrown by the EndpointMiddleware or not

CONCLUSION: So I don’t see what value distinguishing between these two classes of errors would add over just sending back the 400. SO I WILL NOT IMPLEMENT THIS SOLUTION.


The middleware was as follows:

using System.Text;
using System.Text.Json;

namespace problemdetailsmiddleware.Middleware;

public class ProblemDetailsForBadRequestMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ProblemDetailsForBadRequestMiddleware> _logger;

    public ProblemDetailsForBadRequestMiddleware(RequestDelegate next, ILogger<ProblemDetailsForBadRequestMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (BadHttpRequestException ex)
        {
            context.Response.StatusCode = 200;
            await context.Response.WriteAsync(ex.StackTrace ?? "");
            return;

            context.Response.StatusCode = ex.StatusCode;
            _logger.LogError(ex, "BadRequestException occurred while processing HTTP request");
            if (ex.InnerException is JsonException)
            {
                //this would only happen if the 
                // var validationProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]> {
                //     {"request body json", new string[] {@"An error occurred when parsing the provided request body. One of three things is likely to be wrong:

                //     1. The provided request body is not well-formed JSON
                //     2. A required key is missing in the request body JSON
                //     3. A value of an incorrect type is provided for a key in the request body JSON"}}
                // });

                var problem = TypedResults.Problem(
                    statusCode: StatusCodes.Status400BadRequest,
                    type: "http://example.com/problems/invalid-request-body-json",
                    title: "\"An error occurred while parsing request body JSON\","
                    detail: "Request body was provided but an error occurred when parsing it. One of three things is likely to be wrong: 1. The provided request body is not well-formed JSON 2. A required property is missing in the request body JSON 3. A value of an incorrect or incompatible type was provided for a property in the request body JSON"
                    );

                await problem.ExecuteAsync(context);
            }
            else
            {
                var problem = TypedResults.Problem(
                    statusCode: StatusCodes.Status400BadRequest,
                    type: "http://example.com/problems/missing-body-or-invalid-request-parameter-values",
                    title: "\"Request body is missing or a request parameter value is missing or invalid\","
                    detail: "Either the request body is required but missing, or the value of a request parameter - in request headers, query string, route segments or cookies - is either missing ( in case of a required parameter) or invalid (e.g. of an incorrect type). Check your request against the OpenAPI description of the operation."
                );
                await problem.ExecuteAsync(context);
            }
        }
    }

}


public static class ProblemDetailsForBadRequestMiddlewareExtensions
{
    public static IApplicationBuilder UseProblemDetailsForBadRequest(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<ProblemDetailsForBadRequestMiddleware>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Distinguishing between whether the BadHtpRequestException was thrown from EndpointMiddleware of from further down the request processing pipeline:

If every frame in the stack (each is a new line) begins with at Microsoft.AspNetCore.Http.RequestDelegateFactory until potentially a --- End of stack trace from previous location --- line is ensountered, then the exception was thrown from EndpointMiddleware (I believe RequestDelegateFactory create a RequestDelegate out of every middleware in the pipeline that is to be invoked). For example,

   at Microsoft.AspNetCore.Http.RequestDelegateFactory.Log.InvalidJsonRequestBody(HttpContext httpContext, String parameterTypeName, String parameterName, Exception exception, Boolean shouldThrow)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<HandleRequestBodyAndCompileRequestDelegateForJson>g__TryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass102_2.<<HandleRequestBodyAndCompileRequestDelegateForJson>b__2>d.MoveNext()
--- End of stack trace from previous location ---
   at problemdetailsmiddleware.Middleware.ProblemDetailsForBadRequestMiddleware.InvokeAsync(HttpContext context) in C:\MyWork\problemdetails\problemdetailsmiddleware\Middleware\ProblemDetailsForBadRequestMiddleware.cs:line 20
Enter fullscreen mode Exit fullscreen mode

If there are other frames, theses would come from an endpoint filter or from somewhere in the invoked route handler. In this case, not all frames, until the line --- End of stack trace from previous location --- is encountered, would start with at Microsoft.AspNetCore.Http.RequestDelegateFactory, as in this example from a Release build of a minimal API (for some reason the Release build also contained a .pdb, hence the topmost frame even tells youthat the error occurred at line 80):

   at Program.<>c.<<Main>$>b__0_3(IList`1 products, LinkGenerator linkGen, CreateProductArgs createProductArgs) in C:\MyWork\problemdetails\problemdetailsmiddleware\Program.cs:line 80
   at lambda_method4(Closure, EndpointFilterInvocationContext)
   at FluentValidation.AspNetCore.Http.FluentValidationEndpointFilter.InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<ExecuteValueTaskOfObject>g__ExecuteAwaited|129_0(ValueTask`1 valueTask, HttpContext httpContext, JsonTypeInfo`1 jsonTypeInfo)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass102_2.<<HandleRequestBodyAndCompileRequestDelegateForJson>b__2>d.MoveNext()
--- End of stack trace from previous location ---
   at problemdetailsmiddleware.Middleware.ProblemDetailsForBadRequestMiddleware.InvokeAsync(HttpContext context) in C:\MyWork\problemdetails\problemdetailsmiddleware\Middleware\ProblemDetailsForBadRequestMiddleware.cs:line 20
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
philip_zhang_854092d88473 profile image
Philip

Model Binding in ASP.NET Core maps HTTP request data to handler parameters or services. Errors during this process can trigger exceptions, which vary by environment (Development vs. Production). While custom middleware can catch and log these errors, it often adds complexity without significant benefits over a simple 400 response.

EchoAPI can streamline this process by allowing you to simulate various request scenarios during development. This helps you test error handling efficiently before going to Production. Check out EchoAPI echoapi.com/ to simplify API testing.