From 154ca5aaffafae94dba3819e22fa0212c6155cc6 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Fri, 24 Apr 2026 16:47:35 +0200 Subject: [PATCH 1/2] Preserve application/problem+json and application/problem+xml when using ProducesAttribute --- .../Infrastructure/ObjectResultExecutor.cs | 24 ++++++++++----- .../ObjectResultExecutorTest.cs | 30 +++++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs index cb13e3f93b62..711917fb6b80 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Globalization; +using System.Net.Mime; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; @@ -123,18 +124,27 @@ private static void InferContentTypes(ActionContext context, ObjectResult result { Debug.Assert(result.ContentTypes != null); + var wasEmpty = result.ContentTypes.Count == 0; + + // When dealing with ProblemDetails, we always add ProblemJson and ProblemXml at the + // beginning of the list so that they are preferred over anything else. + if (result.Value is ProblemDetails) + { + result.ContentTypes.Insert(0, MediaTypeNames.Application.ProblemJson); + result.ContentTypes.Insert(1, MediaTypeNames.Application.ProblemXml); + } + // If the user sets the content type both on the ObjectResult (example: by Produces) and Response object, - // then the one set on ObjectResult takes precedence over the Response object - var responseContentType = context.HttpContext.Response.ContentType; - if (result.ContentTypes.Count == 0 && !string.IsNullOrEmpty(responseContentType)) + // then the one set on ObjectResult takes precedence over the Response object. + if (!wasEmpty) { - result.ContentTypes.Add(responseContentType); + return; } - if (result.Value is ProblemDetails) + var responseContentType = context.HttpContext.Response.ContentType; + if (!string.IsNullOrEmpty(responseContentType)) { - result.ContentTypes.Add("application/problem+json"); - result.ContentTypes.Add("application/problem+xml"); + result.ContentTypes.Add(responseContentType); } } diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs index be2724e73f40..9e77ff514eee 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs @@ -204,8 +204,11 @@ public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsJsonCont MediaTypeAssert.Equal("application/problem+json; charset=utf-8", httpContext.Response.ContentType); } - [Fact] - public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsXMLContentType_BasedOnAcceptHeader() + [Theory] + [InlineData("text/plain")] + [InlineData("application/xml")] + [InlineData("application/json")] + public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsXMLContentType_BasedOnAcceptHeader(string resultContentTypes) { // Arrange var executor = CreateExecutor(); @@ -216,7 +219,7 @@ public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsXMLConte var result = new ObjectResult(new ProblemDetails()) { - ContentTypes = { "text/plain" }, // This will not be used + ContentTypes = { resultContentTypes }, // This will not be used }; result.Formatters.Add(new TestJsonOutputFormatter()); result.Formatters.Add(new TestXmlOutputFormatter()); // This will be chosen based on the Accept Headers "application/xml" @@ -229,6 +232,27 @@ public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsXMLConte MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType); } + [Fact] + public async Task ExecuteAsync_ForProblemDetailsValue_UsesPlainTextContentType_WhenNoJsonOrXmlFormattersAreAvailable() + { + // Arrange + var executor = CreateExecutor(); + + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext() { HttpContext = httpContext }; + httpContext.Request.Headers.Accept = "text/plain"; + httpContext.Response.ContentType = "text/plain"; + + var result = new ObjectResult(new ProblemDetails()); + result.Formatters.Add(new TestStringOutputFormatter()); + + // Act + await executor.ExecuteAsync(actionContext, result); + + // Assert + MediaTypeAssert.Equal("text/plain; charset=utf-8", httpContext.Response.ContentType); + } + [Fact] public async Task ExecuteAsync_NoContentTypeProvidedForProblemDetails_UsesDefaultContentTypes() { From ec4807eef1c249ae1ec1b7fe0a6e871e461a3df6 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Mon, 27 Apr 2026 10:59:55 +0200 Subject: [PATCH 2/2] Fix for minimal and add test --- .../Http.Results/src/HttpResultsHelper.cs | 6 ++++ .../test/HttpResultsHelperTests.cs | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Http/Http.Results/src/HttpResultsHelper.cs b/src/Http/Http.Results/src/HttpResultsHelper.cs index bf3ac87379fc..20af5c8f417a 100644 --- a/src/Http/Http.Results/src/HttpResultsHelper.cs +++ b/src/Http/Http.Results/src/HttpResultsHelper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Net.Mime; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -34,6 +35,11 @@ public static Task WriteResultAsJsonAsync( return Task.CompletedTask; } + if (value is ProblemDetails) + { + contentType = MediaTypeNames.Application.ProblemJson; + } + jsonSerializerOptions ??= ResolveJsonOptions(httpContext).SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(TValue)); diff --git a/src/Http/Http.Results/test/HttpResultsHelperTests.cs b/src/Http/Http.Results/test/HttpResultsHelperTests.cs index 649a6bb63c3a..28b64574f7cd 100644 --- a/src/Http/Http.Results/test/HttpResultsHelperTests.cs +++ b/src/Http/Http.Results/test/HttpResultsHelperTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -218,6 +219,35 @@ public async Task WriteResultAsJsonAsync_Works_UsingUnspeakableType(bool useJson Assert.Equal("ThreeChild", three.Child); } + [Fact] + public async Task WriteResultAsJsonAsync_SetsProblemJsonContentType_ForProblemDetails() + { + const string Detail = "Explanation of something went wrong :'("; + + // Arrange + var value = new ProblemDetails() + { + Detail = Detail, + }; + var responseBodyStream = new MemoryStream(); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + _ = new NotFound(value); + + // Act + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); + + // Assert + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); + + Assert.NotNull(body); + Assert.Equal(Detail, body.Detail); + Assert.Equal(StatusCodes.Status404NotFound, body.Status); + Assert.Equal("https://tools.ietf.org/html/rfc9110#section-15.5.5", body.Type); + Assert.Equal("Not Found", body.Title); + } + private static async IAsyncEnumerable GetTodosAsync() { yield return new JsonTodo() { Id = 1, IsComplete = true, Name = "One" };