ASP.NET Core 8中[ProducesResponseType(201)]致IProblemDetailsWriter失效问题
我有一个ASP.NET Core Web API控制器动作,返回带自定义类型的201 Created响应,同时配置了自定义异常处理器,用于为400-500错误生成ProblemDetails格式响应。
控制器动作代码如下:
[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)); }
当取消注释[ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)]这一行时,异常处理器会抛出错误:
Unable to find a registered
IProblemDetailsWriterthat can write to the given context
但如果为400或401状态码添加ProducesResponseType,或者为201状态码使用不带typeof(TaskItemCreatedResponse)的ProducesResponseType,则一切正常。经观察,只有为2xx状态码指定带类型的ProducesResponseType时才会触发此问题。
如何在保留[ProducesResponseType(typeof(TaskItemCreatedResponse), 201)]的同时,确保自定义ProblemDetails异常处理器能正常处理错误响应?
编辑:自定义异常处理器相关代码如下
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; } }
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; } }
组件注册代码
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)); }; }); } }
这个问题的核心是:当你为2xx成功响应指定了返回类型后,ASP.NET Core会将该动作的默认响应格式化器限制为只能处理TaskItemCreatedResponse类型,而错误场景下需要返回的ProblemDetails类型不在允许范围内,导致找不到合适的IProblemDetailsWriter。
可以通过以下三种方式解决:
方法一:显式为错误状态码指定ProducesResponseType并声明支持ProblemDetails
在控制器动作上,除了保留201的类型声明,还要为所有可能的错误状态码添加ProducesResponseType,明确指定返回类型为ProblemDetails,同时通过[Produces]属性声明API支持的响应格式(比如JSON),确保格式化器能同时处理成功和错误类型:
[Authorize] [HttpPost] [Produces("application/json")] // 显式声明支持的响应格式 [ProducesResponseType(typeof(TaskItemCreatedResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] public async Task<IActionResult> CreateTaskItem(...) { // 原有代码 }
这样ASP.NET Core会知道该动作既可以返回TaskItemCreatedResponse,也可以返回ProblemDetails,不会限制格式化器的范围。
方法二:配置AddProblemDetails时强制注册默认写入器
当动作指定了特定返回类型后,默认的ProblemDetailsWriter可能未被选中。可以在注册AddProblemDetails时,显式注册内置的默认写入器,确保它能处理所有错误场景:
修改ExceptionHandlingMiddlewareRegistration中的代码:
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); if (context.Exception != null) { context.ProblemDetails.Extensions.Add( "stackTrace", context.Exception.StackTrace .Split( ["\r\n", "\n"], StringSplitOptions.TrimEntries)); } }; }) // 显式注册默认的ProblemDetails写入器 .AddSingleton<IProblemDetailsWriter, DefaultProblemDetailsWriter>();
这个写入器是ASP.NET Core内置的,能处理标准的ProblemDetails类型,确保异常处理器在任何场景下都能找到可用的写入器。
方法三:在异常处理器中手动写入响应(绕过IProblemDetailsService)
如果上述方法都不生效,可以直接在CustomExceptionHandler中手动序列化ProblemDetails并写入响应,避免依赖IProblemDetailsService的自动匹配:
修改CustomExceptionHandler的TryHandleAsync方法:
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 { // 手动序列化ProblemDetails并写入响应 httpContext.Response.ContentType = "application/json"; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); } catch (Exception e) { logger.LogError(e.Message); } return true; }
这种方式完全绕过了IProblemDetailsService的匹配逻辑,直接控制响应的生成,兼容性最好,但需要自己处理序列化细节。
内容的提问来源于stack exchange,提问作者Ucrainex

