Search code examples
c#.net-core.net-6.0asp.net-apicontroller

.NET 6 API populate extended ProblemDetails class with the default response values


I want to return all error responses in my API in the application/problem+json format. By default, returning an empty NotFound() or BadRequest() already results in this format. When they are passed values however (e.g. BadRequest("blah")), they lose this format.

Is there any way to return a ProblemDetails object with additional properties, without having to populate the default ProblemDetails properties by hand? I want to avoid using exception handlers for this, since I don't want to throw exceptions only for the sake of response formatting.

Response should look something like this:

{
  // should be auto-populated with values that an empty NotFound() generates
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-7d554354b54a8e6be652c2ea65434e55-a453edeb85b9eb80-00",
  // what i want to add
  "additionalProperties": {
    "example": "blah"
  }
}

Solution

  • Ok, going off of Giacomo De Liberali answer, I did some digging and found a decent solution.

    I looked up the source code for the default implementation of ProblemDetailsFactory (v6.0.1) and wrote a service that works similarly. This way I can avoid throwing exceptions when I'm returning an fully expected error response.

    I used the IHttpAccessor service instead of passing the HttpContext as parameter. This service needs to be registered beforehand like this in Problem.cs:

    builder.Services.AddHttpContextAccessor();
    

    I populate the default fields for the ProblemDetails instance with the help of the IOptions<ApiBehaviorOptions> service that's registered by default.

    Finally, I pass an optional object as parameter in the Create(..) method and add it's properties with the problemDetails.Extensions.Add(...) method.

    Here's the full implementation I'm using currently:

    public class ProblemService
        {
            private readonly IHttpContextAccessor _contextAccessor;
            private readonly ApiBehaviorOptions _options;
    
            public ProblemService(IOptions<ApiBehaviorOptions> options, IHttpContextAccessor contextAccessor)
            {
                _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
                _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
            }
    
            public ProblemDetails Create(int? statusCode = null, object? extensions = null)
            {
                var context = _contextAccessor.HttpContext ?? throw new NullReferenceException();
    
                statusCode ??= 500;
    
                var problemDetails = new ProblemDetails
                {
                    Status = statusCode,
                    Instance = context.Request.Path
                };
    
                if (extensions != null)
                {
                    foreach (var extension in extensions.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
                    {
                        problemDetails.Extensions.Add(extension.Name, extension.GetValue(extensions, null));
                    }
                }
    
                ApplyProblemDetailsDefaults(context, problemDetails, statusCode.Value);
    
                return problemDetails;
            }
    
            private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode)
            {
                problemDetails.Status ??= statusCode;
    
                if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData))
                {
                    problemDetails.Title ??= clientErrorData.Title;
                    problemDetails.Type ??= clientErrorData.Link;
                }
    
                var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
                if (traceId != null)
                {
                    problemDetails.Extensions["traceId"] = traceId;
                }
            }
        }
    

    And here's how to use it after injecting it into the controller:

    return Unauthorized(_problem.Create(StatusCodes.Status401Unauthorized, new
    {
        I18nKey = LoginFailureTranslationKey.AccessFailed
    ));