I have an ASP.NET Core Web API controller action that returns a 201 Created response with a custom type, and I also have a custom exception handler that writes ProblemDetails for 400–500 errors.
The action looks like this:
[Authorize]
[HttpPost]
//[ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)]
//[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
//[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> CreateTaskItem(
[FromBody] CreateTaskItemRequest query,
CancellationToken cancellationToken = default)
{
var taskItem = await TaskItem.Create(query, usersRepository, cancellationToken);
await taskItemsRepository.AddTaskItem(taskItem, cancellationToken);
await unitOfWorkRepository.SaveChangesAsync(cancellationToken);
return CreatedAtAction(
nameof(CreateTaskItem),
new { id = taskItem.Id },
new TaskItemCreatedResponse(
taskItem.Id,
taskItem.Title,
taskItem.Description,
taskItem.IsCompleted,
taskItem.UserId));
}
If I uncomment the line with [ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)] then my exception handler fails with:
Unable to find a registered
IProblemDetailsWriterthat can write to the given context
However if I use ProducesResponseType for 400 or 401 - or ProducesResponseType for 201 but without the typeof(TaskItemCreatedResponse) part it works fine.
As far as I know, the problem arises only if I use ProducesResponseType with 2xx status codes
How can I use [ProducesResponseType(typeof(TaskItemCreatedResponse), 201)] while keeping my custom ProblemDetails exception handler working for error responses?
Edit:
My CustomExceptionHandler
public class CustomExceptionHandler(
IProblemDetailsService problemDetailService,
ILogger<CustomExceptionHandler> logger,
IExceptionMapper mapper)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken = default)
{
logger.LogError(
exception, "Exception occurred: {Message}", exception.Message);
var problemDetails = mapper.MapException(httpContext, exception);
try
{
await problemDetailService.WriteAsync(
new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = problemDetails
});
}
catch (Exception e)
{
logger.LogError(e.Message);
}
return true;
}
}
I use my ExceptionMapper that inherits IExceptionMapper and is registered through DI. This mapper maps my custom exceptions
IExceptionMapper
public interface IExceptionMapper
{
ProblemDetails MapException(HttpContext httpContext, Exception exception);
}
ExceptionMapper
public class ExceptionMapper : IExceptionMapper
{
public ProblemDetails MapException(
HttpContext httpContext,
Exception exception)
{
int statusCode;
string title;
var errors = new Dictionary<string, string[]>();
switch(exception)
{
case ValidationException e:
statusCode = 400;
title = "Validation failed";
errors = e.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
break;
case BadRequestException:
statusCode = 400;
title = "Error caused by request";
break;
case UnauthorizedException:
statusCode = 401;
title = "Unauthorized access";
break;
case NotFoundException:
statusCode = 404;
title = "Resource cannot be found";
break;
default:
statusCode = 500;
title = "Internal server problem";
break;
}
httpContext.Response.StatusCode = statusCode;
var problemDetails = new ProblemDetails
{
Type = exception.GetType().Name,
Status = statusCode,
Title = title
};
if (errors.Count() == 0)
problemDetails.Detail = exception.Message;
else
problemDetails.Extensions.Add("errors", errors);
return problemDetails;
}
}
Then everything is registered in ExceptionHandlingMiddlewareRegistration
public static class ExceptionHandlingMiddlewareRegistration
{
public static void RegisterExceptionHandling(this IServiceCollection serviceCollection)
{
serviceCollection.AddExceptionHandler<CustomExceptionHandler>();
serviceCollection.AddSingleton<IExceptionMapper, ExceptionMapper>();
serviceCollection.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance =
$"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier);
Activity? activity = context.HttpContext.Features.Get<IHttpActivityFeature>()?.Activity;
context.ProblemDetails.Extensions.TryAdd("traceId", activity?.Id);
context.ProblemDetails.Extensions.Add(
"stackTrace",
context.Exception.StackTrace
.Split(
["\r\n", "\n"],
StringSplitOptions.TrimEntries));
};
});
}
}