Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0f7cefb
api explorer support
DeagleGross Mar 3, 2026
c97f70b
api explorer
DeagleGross Mar 3, 2026
7b4f6aa
mvc support
DeagleGross Mar 4, 2026
dc11280
openapi oneof
DeagleGross Mar 4, 2026
c90e66d
Merge branch 'main' into dmkorolev/multiple-produces
DeagleGross Mar 4, 2026
bf1ddeb
rewrite filters
DeagleGross Mar 4, 2026
1de48e6
rewrite .verified
DeagleGross Mar 4, 2026
45c060a
fix invariant verification
DeagleGross Mar 5, 2026
4bd7c48
fix different scope overrides
DeagleGross Mar 5, 2026
2dc09bd
wip
DeagleGross Mar 6, 2026
8ddc9fd
Merge branch 'main' into dmkorolev/multiple-produces
DeagleGross Mar 6, 2026
c49578c
add description to mergine of formats
DeagleGross Mar 6, 2026
b84fec5
renaming
DeagleGross Mar 6, 2026
1a9b091
simplify
DeagleGross Mar 6, 2026
275fa44
rework tests to use Assert.Collection without ordering (ensure order …
DeagleGross Mar 6, 2026
abed03f
properly validate inferred types against excplitly defined ones
DeagleGross Mar 6, 2026
e99727c
tests
DeagleGross Mar 6, 2026
cf570db
elaborate on inferred types
DeagleGross Mar 9, 2026
0872027
simplify
DeagleGross Mar 12, 2026
6179e23
tests for controllers
DeagleGross Mar 12, 2026
6135446
minimal api tests
DeagleGross Mar 12, 2026
3186309
nit
DeagleGross Mar 12, 2026
f28fd3e
tests on Results.Ok and TypedResults.OK()
DeagleGross Mar 12, 2026
cb64b6d
nit
DeagleGross Mar 12, 2026
7436ce4
add explanation for filtering non-ApiResponseMetadataProviders
DeagleGross Mar 12, 2026
9b0920c
update .verified to include inferred types
DeagleGross Mar 12, 2026
9c69c14
include hiding inferred-type scenario
DeagleGross Mar 12, 2026
85d43c3
tests on groups
DeagleGross Mar 18, 2026
002fd0f
comments 1
DeagleGross Mar 18, 2026
3e76276
nit 2
DeagleGross Mar 18, 2026
98675d1
change to anyOf
DeagleGross Mar 27, 2026
5c621ce
merge main (tmp)
DeagleGross May 4, 2026
e748258
fix deterministic ordering in api explorer
DeagleGross May 4, 2026
8c0a65b
regen openapi integration tests
DeagleGross May 4, 2026
e740fc1
and fix name of route
DeagleGross May 4, 2026
3ba070b
add Debug.Assert to apiresponsetypeprovider scope processing
DeagleGross May 4, 2026
d9854e0
better in-place doc
DeagleGross May 4, 2026
7f8e8b9
fix naming of mvc-controller oneOf->anyOf
DeagleGross May 4, 2026
4daa5a6
explanations + test for corner case
DeagleGross May 4, 2026
af004d8
make it readonly
DeagleGross May 4, 2026
dbd3ad9
merge descriptions
DeagleGross May 13, 2026
f254424
Merge remote-tracking branch 'upstream/main' into dmkorolev/multiple-…
DeagleGross May 13, 2026
3dec050
fix test validating merge
DeagleGross May 13, 2026
b06df29
add duplicate description case for verification
DeagleGross May 14, 2026
9ea2818
fix nit formatting
DeagleGross May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer;

internal sealed class ApiResponseTypeProvider
{
internal readonly record struct ResponseKey(int StatusCode, Type? DeclaredType, string? ContentType);
Comment thread
DeagleGross marked this conversation as resolved.
Outdated

private readonly IModelMetadataProvider _modelMetadataProvider;
private readonly IActionResultTypeMapper _mapper;
private readonly MvcOptions _mvcOptions;
Expand Down Expand Up @@ -89,7 +91,7 @@ private ICollection<ApiResponseType> GetApiResponseTypes(

// Read response metadata from providers and
// overwrite responseTypes from the metadata based
// on the status code
// on the status code and content type
var responseTypesFromProvider = ReadResponseMetadata(
responseMetadataAttributes,
type,
Expand All @@ -98,6 +100,22 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
out var _,
responseTypeMetadataProviders);

var responseProviderStatusCodes = responseTypesFromProvider.Values
.Select(responseType => responseType.StatusCode)
.ToHashSet();

// Preserve existing source precedence: when metadata providers/attributes define a given status code,
// entries for that status code discovered from endpoint metadata are removed before merge.
// This keeps provider metadata authoritative per status code while still allowing multiple provider
// entries for the same status code when their keys differ (type/content-type).
foreach (var existingResponseType in responseTypes.Keys.ToList())
{
if (responseProviderStatusCodes.Contains(existingResponseType.StatusCode))
{
responseTypes.Remove(existingResponseType);
Comment thread
DeagleGross marked this conversation as resolved.
Outdated
}
}

foreach (var responseType in responseTypesFromProvider)
{
responseTypes[responseType.Key] = responseType.Value;
Expand All @@ -106,7 +124,8 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
// Set the default status only when no status has already been set explicitly
if (responseTypes.Count == 0 && type != null)
{
responseTypes.Add(StatusCodes.Status200OK, new ApiResponseType
var defaultKey = new ResponseKey(StatusCodes.Status200OK, type, null);
responseTypes.Add(defaultKey, new ApiResponseType
{
StatusCode = StatusCodes.Status200OK,
Type = type,
Expand All @@ -128,11 +147,15 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider);
}

return responseTypes.Values;
return responseTypes.Values
.OrderBy(responseType => responseType.StatusCode)
.ThenBy(responseType => responseType.Type?.Name)
.ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType)
.ToList();
Comment thread
DeagleGross marked this conversation as resolved.
}

// Shared with EndpointMetadataApiDescriptionProvider
internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(
internal static Dictionary<ResponseKey, ApiResponseType> ReadResponseMetadata(
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
Type? type,
Type? defaultErrorType,
Expand All @@ -142,7 +165,7 @@ internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(
IModelMetadataProvider? modelMetadataProvider = null)
{
errorSetByDefault = false;
var results = new Dictionary<int, ApiResponseType>();
var results = new Dictionary<ResponseKey, ApiResponseType>();

// Get the content type that the action explicitly set to support.
// Walk through all 'filter' attributes in order, and allow each one to see or override
Expand Down Expand Up @@ -204,30 +227,33 @@ internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(
// action/controller/etc. In that scenario, instead of picking the most-specific
// set of content types (like we do with the Produces attribute above) we process
// the content types for each attribute independently.
string? keyContentType = null;
if (metadataAttribute is ProducesResponseTypeAttribute)
{
var attributeContentTypes = new MediaTypeCollection();
metadataAttribute.SetContentTypes(attributeContentTypes);
CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider);
keyContentType = attributeContentTypes.FirstOrDefault();
Comment thread
DeagleGross marked this conversation as resolved.
Outdated
}

if (apiResponseType.Type != null)
{
results[apiResponseType.StatusCode] = apiResponseType;
var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, keyContentType);
results[key] = apiResponseType;
}
}
}

return results;
}

internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(
internal static Dictionary<ResponseKey, ApiResponseType> ReadResponseMetadata(
IReadOnlyList<IProducesResponseTypeMetadata> responseMetadata,
Type? type,
IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders = null,
IModelMetadataProvider? modelMetadataProvider = null)
{
var results = new Dictionary<int, ApiResponseType>();
var results = new Dictionary<ResponseKey, ApiResponseType>();

foreach (var metadata in responseMetadata)
{
Expand Down Expand Up @@ -270,7 +296,26 @@ internal static Dictionary<int, ApiResponseType> ReadResponseMetadata(

if (apiResponseType.Type != null)
{
results[apiResponseType.StatusCode] = apiResponseType;
// If metadata explicitly specifies a different type for this status code than the inferred
// return type, drop the inferred entry for that status code. This preserves long-standing
// behavior where explicit metadata takes precedence over inference while still allowing
// multiple explicit entries for the same status code.
if (type != null &&
type != typeof(void) &&
apiResponseType.Type != type)
Comment thread
halter73 marked this conversation as resolved.
Outdated
{
foreach (var existingResponseKey in results.Keys.ToList())
{
if (existingResponseKey.StatusCode == apiResponseType.StatusCode &&
existingResponseKey.DeclaredType == type)
{
results.Remove(existingResponseKey);
}
}
}

var key = new ResponseKey(apiResponseType.StatusCode, apiResponseType.Type, metadata.ContentTypes?.FirstOrDefault());
results[key] = apiResponseType;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,9 @@ private static void AddSupportedResponseTypes(

// We favor types added via the extension methods (which implements IProducesResponseTypeMetadata)
// over those that are added via attributes.
var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat(responseProviderMetadataTypes.Values);
var producesStatusCodes = producesResponseMetadataTypes.Values.Select(metadata => metadata.StatusCode).ToHashSet();
var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat(
responseProviderMetadataTypes.Values.Where(metadata => !producesStatusCodes.Contains(metadata.StatusCode)));

if (responseMetadataTypes.Any())
{
Expand Down Expand Up @@ -377,7 +379,10 @@ private static void AddSupportedResponseTypes(

apiResponseType.Description ??= GetMatchingResponseTypeDescription(responseProviderMetadataTypes.Values, apiResponseType);

if (!supportedResponseTypes.Any(existingResponseType => existingResponseType.StatusCode == apiResponseType.StatusCode))
if (!supportedResponseTypes.Any(existingResponseType =>
existingResponseType.StatusCode == apiResponseType.StatusCode &&
existingResponseType.Type == apiResponseType.Type &&
existingResponseType.ApiResponseFormats.FirstOrDefault()?.MediaType == apiResponseType.ApiResponseFormats.FirstOrDefault()?.MediaType))
{
supportedResponseTypes.Add(apiResponseType);
}
Expand All @@ -398,6 +403,21 @@ private static void AddSupportedResponseTypes(
supportedResponseTypes.Add(defaultApiResponseType);
}

if (supportedResponseTypes.Count > 1)
{
var orderedSupportedResponseTypes = supportedResponseTypes
Comment thread
DeagleGross marked this conversation as resolved.
Outdated
.OrderBy(responseType => responseType.StatusCode)
.ThenBy(responseType => responseType.Type?.Name)
.ThenBy(responseType => responseType.ApiResponseFormats.FirstOrDefault()?.MediaType)
.ToList();

supportedResponseTypes.Clear();
foreach (var orderedSupportedResponseType in orderedSupportedResponseTypes)
{
supportedResponseTypes.Add(orderedSupportedResponseType);
}
}

static string? GetMatchingResponseTypeDescription(IEnumerable<ApiResponseType> responseMetadataTypes, ApiResponseType apiResponseType)
{
// We set the Description to the LAST non-null value we find that matches the status code.
Expand Down
153 changes: 118 additions & 35 deletions src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,41 +97,35 @@ public void GetApiResponseTypes_CombinesFilters()
var result = provider.GetApiResponseTypes(actionDescriptor);

// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(201, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format =>
{
Assert.Equal("application/json", format.MediaType);
Assert.IsType<TestOutputFormatter>(format.Formatter);
});
},
responseType =>
{
Assert.Equal(400, responseType.StatusCode);
Assert.Equal(typeof(ProblemDetails), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format =>
{
Assert.Equal("application/json", format.MediaType);
Assert.IsType<TestOutputFormatter>(format.Formatter);
});
},
responseType =>
{
Assert.Equal(404, responseType.StatusCode);
Assert.Equal(typeof(void), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Empty(responseType.ApiResponseFormats);
});
Assert.Equal(5, result.Count);

Assert.Contains(result, responseType =>
responseType.StatusCode == 201 &&
responseType.Type == typeof(object) &&
responseType.ApiResponseFormats.Count == 1 &&
responseType.ApiResponseFormats[0].MediaType == "application/json");

Assert.Contains(result, responseType =>
responseType.StatusCode == 201 &&
responseType.Type == typeof(BaseModel) &&
responseType.ApiResponseFormats.Count == 1 &&
responseType.ApiResponseFormats[0].MediaType == "application/json");

Assert.Contains(result, responseType =>
responseType.StatusCode == 400 &&
responseType.Type == typeof(ProblemDetails) &&
responseType.ApiResponseFormats.Count == 1 &&
responseType.ApiResponseFormats[0].MediaType == "application/json");

Assert.Contains(result, responseType =>
responseType.StatusCode == 400 &&
responseType.Type == typeof(void) &&
responseType.ApiResponseFormats.Count == 0);

Assert.Contains(result, responseType =>
responseType.StatusCode == 404 &&
responseType.Type == typeof(void) &&
responseType.ApiResponseFormats.Count == 0);
}

[Fact]
Expand Down Expand Up @@ -810,6 +804,19 @@ public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces
});
}

[Fact]
public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeWithSameStatusCodeButDifferentTypesWithoutContentTypes()
{
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetMultipleTypes));
var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions());

var result = provider.GetApiResponseTypes(actionDescriptor);

Assert.Equal(2, result.Count);
Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(BaseModel));
Assert.Contains(result, responseType => responseType is { StatusCode: 200, Type: not null } && responseType.Type == typeof(string));
}

[Fact]
public void GetApiResponseTypes_ReturnNoResponseTypes_IfActionWithBuiltIResultReturnType()
{
Expand Down Expand Up @@ -889,6 +896,10 @@ public class TestController

public IResult GetIResult(int id) => null;

[ProducesResponseType(typeof(BaseModel), 200)]
[ProducesResponseType(typeof(string), 200)]
public IResult GetMultipleTypes() => Results.Ok();

public MyResponse GetCustomIResult() => new MyResponse { Content = "Test Content" };
}

Expand All @@ -912,6 +923,78 @@ public TestOutputFormatter()
public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context) => Task.CompletedTask;
}

[Fact]
public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeWithSameStatusCodeButDifferentContentTypes()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(
typeof(MultipleProducesForSameStatusCodeController),
nameof(MultipleProducesForSameStatusCodeController.Get));

var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions());

// Act
var result = provider.GetApiResponseTypes(actionDescriptor);

// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name),
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType));
},
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(string), responseType.Type);
Assert.Equal(new[] { "text/html" }, GetSortedMediaTypes(responseType));
});
}

public class MultipleProducesForSameStatusCodeController : ControllerBase
{
[ProducesResponseType(typeof(BaseModel), 200, "application/json")]
[ProducesResponseType(typeof(string), 200, "text/html")]
public IActionResult Get() => null;
}

[Fact]
public void GetApiResponseTypes_PreservesMultipleProducesResponseTypeFromEndpointMetadata()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(
typeof(MultipleProducesForSameStatusCodeController),
nameof(MultipleProducesForSameStatusCodeController.Get));
actionDescriptor.EndpointMetadata =
[
new ProducesResponseTypeMetadata(200, typeof(BaseModel), ["application/json"]),
new ProducesResponseTypeMetadata(200, typeof(string), ["text/html"]),
];

var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions());

// Act
var result = provider.GetApiResponseTypes(actionDescriptor);

// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode).ThenBy(r => r.Type?.Name),
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType));
},
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(string), responseType.Type);
Assert.Equal(new[] { "text/html" }, GetSortedMediaTypes(responseType));
});
}

public static class SearchApiConventions
{
[ProducesResponseType(206)]
Expand Down
Loading
Loading