aspnetcore: Allow consistent `Problem Details` generation

Background and Motivation

API Controllers have a mechanism to auto generated Problem Details (https://datatracker.ietf.org/doc/html/rfc7807) for API Controller ActionResult. The mechanism is enabled by default for all API Controllers, however, it will only generates a Problem Details payload when the API Controller Action is processed and produces a HTTP Status Code 400+ and no Response Body, that means, scenarios like - unhandled exceptions, routing issues - won’t produce a Problem Details payload.

Here is overview of when the mechanism will produce the payload:

❌ = Not generated ✅ = Automatically generated

Routing issues: ❌

Unhandled Exceptions: ❌

MVC

  • StatusCodeResult 400 and up: ✅ (based on SuppressMapClientErrors)
    • BadRequestResult and UnprocessableEntityResult are StatusCodeResult
  • ObjectResult: ❌ (Unless a ProblemDetails is specified in the input)
    • eg.: BadRequestObjectResult and UnprocessableEntityObjectResult
  • 415 UnsupportedMediaType: ✅ (Unless when a ConsumesAttribute is defined)
  • 406 NotAcceptable: ❌ (when happens in the output formatter)

Minimal APIs won’t generate a Problem Details payload as well.

Here are some examples of reported issues by the community:

Proposed API

🎯 The goal of the proposal is to have the ProblemDetails generated, for all Status 400+ (except in Minimal APIs - for now) but the user need to opt-in and also have a mechanism that allows devs or library authors (eg. API Versioning) generate ProblemDetails responses when opted-in by the users.

An important part of the proposed design is the auto generation will happen only when a Body content is not provided, even when the content is a ProblemDetails that means scenario, similar to the sample below, will continue generate the ProblemDetails specified by the user and will not use any of the options to suppress the generation:

public ActionResult GetBadRequestOfT() => BadRequest(new ProblemDetails());

Overview:

  • Minimal APIs will not have an option to autogenerate ProblemDetails.
  • Exception Handler Middleware will autogenerate ProblemDetails only when no ExceptionHandler or ExceptionHandlerPath is provided, the IProblemDetailsService is registered,ProblemDetailsOptions.AllowedProblemTypes contains Server and a IProblemMetadata is added to the current endpoint.
  • Developer Exception Page Middleware will autogenerate ProblemDetails only when detected that the client does not accept text/html, the IProblemDetailsService is registered,ProblemDetailsOptions.AllowedProblemTypes contains Server and a IProblemMetadata is added to the current endpoint.
  • Status Code Pages Middleware default handler will generate a ProblemDetails only when detected the IProblemDetailsService is registered and the ProblemType requested is allowed and a IProblemMetadata is added to the current endpoint.
  • A call to AddProblemDetails is required and will register the IProblemDetailsService and a DefaultProblemDetailsWriter.
  • A call to AddProblemDetails is required and will register the IProblemDetailsService and a DefaultProblemDetailsWriter.
  • When APIBehaviorOptions.SuppressMapClientErrors is false, a IProblemMetadata will be added to all API Controller Actions.
  • MVC will have an implementation of IProblemDetailsWriter that allows content-negotiation that will be used for API Controllers, routing and exceptions. The payload will be generated only when a APIBehaviorMetadata is included in the endpoint.
  • Addition Problem Details configuration, using ProblemDetailsOptions.ConfigureDetails, will be applied for all autogenerated payload, including BadRequest responses caused by validation issues.
  • MVC 406 NotAcceptable response (auto generated) will only autogenerate the payload when ProblemDetailsOptions.AllowedMapping contains Routing.
  • Routing issues (404, 405, 415) will only autogenerate the payload when ProblemDetailsOptions.AllowedMapping contains Routing.

A detailed spec is here.

namespace Microsoft.Extensions.DependencyInjection;

+public static class ProblemDetailsServiceCollectionExtensions
+{
+    public static IServiceCollection AddProblemDetails(this IServiceCollection services) { }
+    public static IServiceCollection AddProblemDetails(this IServiceCollection services, Action<ProblemDetailsOptions> configureOptions) +{}
+}
namespace Microsoft.AspNetCore.Http;

+public class ProblemDetailsOptions
+{
+    public ProblemTypes AllowedProblemTypes { get; set; } = ProblemTypes.All;
+    public Action<HttpContext, ProblemDetails>? ConfigureDetails { get; set; }
+}

+[Flags]
+public enum ProblemTypes: uint
+{
+    Unspecified = 0,
+    Server = 1,
+    Routing = 2,
+    Client = 4,
+    All = RoutingFailures | Exceptions | ClientErrors,
+}

+public interface IProblemDetailsWriter
+{
+   bool CanWrite(HttpContext context);
+   Task WriteAsync(HttpContext context, int? statusCode, string? title, string? type, string? detail, string? instance, IDictionary<string, object?>? extensions);
+}

+public interface IProblemDetailsService
+{
+      bool IsEnabled(ProblemTypes type);
+      Task WriteAsync(HttpContext context, EndpointMetadataCollection? currentMetadata = null, int?  statusCode = null, string? title = null, string? type = null, string? detail = null, string? instance = null, IDictionary<string, object?>?  extensions = null);
+}
namespace Microsoft.AspNetCore.Http.Metadata;

+public interface IProblemMetadata
+{
+    public int? StatusCode { get; }
+    public ProblemTypes ProblemType { get; }
+}
namespace Microsoft.AspNetCore.Diagnostics;

public class ExceptionHandlerMiddleware
{
    public ExceptionHandlerMiddleware(
        RequestDelegate next,
        ILoggerFactory loggerFactory,
        IOptions<ExceptionHandlerOptions> options,
        DiagnosticListener diagnosticListener,
+       IProblemDetailsService? problemDetailsService = null)
    {}
}

public class DeveloperExceptionPageMiddleware
{
    public DeveloperExceptionPageMiddleware(
        RequestDelegate next,
        IOptions<DeveloperExceptionPageOptions> options,
        ILoggerFactory loggerFactory,
        IWebHostEnvironment hostingEnvironment,
        DiagnosticSource diagnosticSource,
        IEnumerable<IDeveloperPageExceptionFilter> filters,
+       IProblemDetailsService? problemDetailsService = null)
    {}
}

Usage Examples

AddProblemDetails

Default options

var builder = WebApplication.CreateBuilder(args);

// Add services to the containers
builder.Services.AddControllers();
builder.Services.AddProblemDetails();

var app = builder.Build();

// When problemdetails is enabled this overload will work even
// when the ExceptionPath or ExceptionHadler are not configured
app.UseExceptionHandler();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

//Generate PD for 400+
app.UseStatusCodePages();

app.MapControllers();
app.Run();

Custom Options

var builder = WebApplication.CreateBuilder(args);

// Add services to the containers
builder.Services.AddControllers();
builder.Services.AddProblemDetails(options => { 

    options.AllowedProblemTypes = ProblemTypes.Server | ProblemTypes.Client | ProblemTypes.Routing;
    options.ConfigureDetails = (context, problemdetails) => 
    {
       problemdetails.Extensions.Add("my-extension", new { Property = "value" });
    };
});

var app = builder.Build();

// When Problem Details is enabled this overload will work even
// when the ExceptionPath or ExceptionHadler are not configured
app.UseExceptionHandler();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app.MapControllers();
app.Run();

Creating a custom ProblemDetails writer

public class CustomWriter : IProblemDetailsWriter
{
    public bool CanWrite(HttpContext context) 
        => context.Response.StatusCode == 400;

    public Task WriteAsync(HttpContext context, int? statusCode, string? title, string? type, string? detail, string? instance, IDictionary<string, object?>? extensions) 
        => context.Response.WriteAsJsonAsync(CreateProblemDetails(statusCode, title, type, detail, instance, extensions));

    private object CreateProblemDetails(int? statusCode, string? title, string? type, string? detail, string? instance, IDictionary<string, object?>? extensions)
    {
        throw new NotImplementedException();
    }
}

// the new write need to be registered
builder.Services.AddSingleton<IProblemDetailsWriter, CustomWriter>();

Writing a Problem Details response with IProblemDetailsService

public Task WriteProblemDetails(HttpContext httpContext)
{
  httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;

  if (context.RequestServices.GetService<IProblemDetailsService>() is { } problemDetailsService)
  {
      return problemDetailsService.WriteAsync(context);
  }

  return Task.CompletedTask;
} 

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 1
  • Comments: 21 (17 by maintainers)

Most upvoted comments

Cool, thanks for the example. That’s pretty much what I was thinking. The trick with the writers however will be ensuring you add them in the correct order since it’s the first CanWrite that wins. Not a big deal of course

Quick API review notes:

  • This looks good to me. We don’t expect there to be many IProblemDetailsWriter implementations or callers, so making it slightly clunkier to improve perf seems worthwhile.

API with update approved!

namespace Microsoft.Extensions.DependencyInjection;

+public static class ProblemDetailsServiceCollectionExtensions
+{
+    public static IServiceCollection AddProblemDetails(this IServiceCollection services) { }
+    public static IServiceCollection AddProblemDetails(this IServiceCollection services, Action<ProblemDetailsOptions>? configure) { }
+}

namespace Microsoft.AspNetCore.Http;

+public class ProblemDetailsOptions
+{
+    public Action<ProblemDetailsContext>? CustomizeProblemDetails { get; set; }
+}

+public class ProblemDetailsContext
+{
+    public required HttpContext HttpContext { get; init; }
+    public EndpointMetadataCollection? AdditionalMetadata { get; init; }
+    public ProblemDetails ProblemDetails { get; init; } = new ProblemDetails();
+}

+public interface IProblemDetailsWriter
+{
+    bool CanWrite(ProblemDetailsContext context); 
+    ValueTask WriteAsync(ProblemDetailsContext context);
+}

+public interface IProblemDetailsService
+{
+     ValueTask WriteAsync(ProblemDetailsContext context);
+}