diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index f6c2bd724010..d1f151812ae6 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -49,7 +50,8 @@ public ICollection GetApiResponseTypes(ControllerActionDescript defaultErrorType = ((ProducesErrorResponseTypeAttribute)result!).Type; } - var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, runtimeReturnType, defaultErrorType); + var producesResponseMetadata = action.EndpointMetadata.OfType().ToList(); + var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, producesResponseMetadata, runtimeReturnType, defaultErrorType); return apiResponseTypes; } @@ -72,6 +74,7 @@ private static List GetResponseMetadataAttributes( private ICollection GetApiResponseTypes( IReadOnlyList responseMetadataAttributes, + IReadOnlyList producesResponseMetadata, Type? type, Type defaultErrorType) { @@ -79,16 +82,30 @@ private ICollection GetApiResponseTypes( var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); var responseTypes = ReadResponseMetadata( + producesResponseMetadata, + type, + responseTypeMetadataProviders, + _modelMetadataProvider); + + // Read response metadata from providers and + // overwrite responseTypes from the metadata based + // on the status code + var responseTypesFromProvider = ReadResponseMetadata( responseMetadataAttributes, type, defaultErrorType, contentTypes, responseTypeMetadataProviders); + foreach (var responseType in responseTypesFromProvider) + { + responseTypes[responseType.Key] = responseType.Value; + } + // Set the default status only when no status has already been set explicitly if (responseTypes.Count == 0 && type != null) { - responseTypes.Add(new ApiResponseType + responseTypes.Add(StatusCodes.Status200OK, new ApiResponseType { StatusCode = StatusCodes.Status200OK, Type = type, @@ -105,16 +122,16 @@ private ICollection GetApiResponseTypes( contentTypes.Add((string)null!); } - foreach (var apiResponse in responseTypes) + foreach (var apiResponse in responseTypes.Values) { CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider); } - return responseTypes; + return responseTypes.Values; } // Shared with EndpointMetadataApiDescriptionProvider - internal static List ReadResponseMetadata( + internal static Dictionary ReadResponseMetadata( IReadOnlyList responseMetadataAttributes, Type? type, Type defaultErrorType, @@ -195,7 +212,55 @@ internal static List ReadResponseMetadata( } } - return results.Values.ToList(); + return results; + } + + internal static Dictionary ReadResponseMetadata( + IReadOnlyList responseMetadata, + Type? type, + IEnumerable? responseTypeMetadataProviders = null, + IModelMetadataProvider? modelMetadataProvider = null) + { + var results = new Dictionary(); + + foreach (var metadata in responseMetadata) + { + var statusCode = metadata.StatusCode; + + var apiResponseType = new ApiResponseType + { + Type = metadata.Type, + StatusCode = statusCode, + }; + + if (apiResponseType.Type == typeof(void)) + { + if (type != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) + { + // Allow setting the response type from the return type of the method if it has + // not been set explicitly by the method. + apiResponseType.Type = type; + } + } + + var attributeContentTypes = new MediaTypeCollection(); + if (metadata.ContentTypes != null) + { + foreach (var contentType in metadata.ContentTypes) + { + attributeContentTypes.Add(contentType); + } + } + + CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider); + + if (apiResponseType.Type != null) + { + results[apiResponseType.StatusCode] = apiResponseType; + } + } + + return results; } // Shared with EndpointMetadataApiDescriptionProvider diff --git a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs index bab24c51e647..e189fdac0769 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs @@ -115,8 +115,6 @@ private ApiDescription CreateApiDescription( apiDescription.ParameterDescriptions.Add(parameter); } - var requestMetadataAttributes = GetRequestMetadataAttributes(action); - var apiResponseTypes = _responseTypeProvider.GetApiResponseTypes(action); foreach (var apiResponseType in apiResponseTypes) { @@ -127,7 +125,11 @@ private ApiDescription CreateApiDescription( // could end up with duplicate data. if (apiDescription.ParameterDescriptions.Count > 0) { - var contentTypes = GetDeclaredContentTypes(requestMetadataAttributes); + // Get the most significant accepts metadata + var acceptsMetadata = action.EndpointMetadata.OfType().LastOrDefault(); + var requestMetadataAttributes = GetRequestMetadataAttributes(action); + + var contentTypes = GetDeclaredContentTypes(requestMetadataAttributes, acceptsMetadata); foreach (var parameter in apiDescription.ParameterDescriptions) { if (parameter.Source == BindingSource.Body) @@ -449,11 +451,23 @@ private IReadOnlyList GetSupportedFormats(MediaTypeCollection return results; } - internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList? requestMetadataAttributes) + internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList? requestMetadataAttributes, IAcceptsMetadata? acceptsMetadata) { + var contentTypes = new MediaTypeCollection(); + + // Walking the content types from the accepts metadata first + // to allow any RequestMetadataProvider to see or override any accepts metadata + // keeping the current behavior. + if (acceptsMetadata != null) + { + foreach (var contentType in acceptsMetadata.ContentTypes) + { + contentTypes.Add(contentType); + } + } + // Walk through all 'filter' attributes in order, and allow each one to see or override // the results of the previous ones. This is similar to the execution path for content-negotiation. - var contentTypes = new MediaTypeCollection(); if (requestMetadataAttributes != null) { foreach (var metadataAttribute in requestMetadataAttributes) diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index c8ae7b48af73..931936844078 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -345,11 +345,11 @@ private static void AddSupportedResponseTypes( var responseProviderMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata( responseProviderMetadata, responseType, defaultErrorType, contentTypes); - var producesResponseMetadataTypes = ReadResponseMetadata(producesResponseMetadata, responseType); + var producesResponseMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(producesResponseMetadata, responseType); // We favor types added via the extension methods (which implements IProducesResponseTypeMetadata) // over those that are added via attributes. - var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat(responseProviderMetadataTypes); + var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat(responseProviderMetadataTypes.Values); if (responseMetadataTypes.Any()) { @@ -397,51 +397,6 @@ private static void AddSupportedResponseTypes( } } - private static Dictionary ReadResponseMetadata( - IReadOnlyList responseMetadata, - Type? type) - { - var results = new Dictionary(); - - foreach (var metadata in responseMetadata) - { - var statusCode = metadata.StatusCode; - - var apiResponseType = new ApiResponseType - { - Type = metadata.Type, - StatusCode = statusCode, - }; - - if (apiResponseType.Type == typeof(void)) - { - if (type != null && (statusCode == StatusCodes.Status200OK || statusCode == StatusCodes.Status201Created)) - { - // Allow setting the response type from the return type of the method if it has - // not been set explicitly by the method. - apiResponseType.Type = type; - } - } - - var attributeContentTypes = new MediaTypeCollection(); - if (metadata.ContentTypes != null) - { - foreach (var contentType in metadata.ContentTypes) - { - attributeContentTypes.Add(contentType); - } - } - ApiResponseTypeProvider.CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders: null, modelMetadataProvider: null); - - if (apiResponseType.Type != null) - { - results[apiResponseType.StatusCode] = apiResponseType; - } - } - - return results; - } - private static ApiResponseType CreateDefaultApiResponseType(Type responseType) { var apiResponseType = new ApiResponseType diff --git a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs index 0490be15740a..ffb431bf6c75 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs @@ -514,6 +514,47 @@ public void GetApiDescription_PopulatesResponseType_ForActionResultOfT(string me Assert.NotNull(responseType.ModelMetadata); } + [Theory] + [InlineData(nameof(ReturnsResultOfProductWithEndpointMetadata))] + [InlineData(nameof(ReturnsTaskOfResultOfProductWithEndpointMetadata))] + public void GetApiDescription_PopulatesResponseType_ForResultOfT_WithEndpointMetadata(string methodName) + { + // Arrange + var action = CreateActionDescriptor(methodName); + action.EndpointMetadata = new List() { new ProducesResponseTypeMetadata(typeof(Product), 200) }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(Product), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); + } + + [Theory] + [InlineData(nameof(ReturnsResultOfProductWithEndpointMetadata))] + [InlineData(nameof(ReturnsTaskOfResultOfProductWithEndpointMetadata))] + public void GetApiDescription_PopulatesResponseType_ForResultOfT_WithEndpointMetadata_PreferProducesAttribute(string methodName) + { + // Arrange + var action = CreateActionDescriptor(methodName); + action.EndpointMetadata = new List() { new ProducesResponseTypeMetadata(typeof(Product), 200) }; + action.FilterDescriptors = new List{ + new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(Customer), 200), FilterScope.Action) + }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(Customer), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); + } + [Theory] [InlineData(nameof(ReturnsActionResultOfSequenceOfProducts))] [InlineData(nameof(ReturnsTaskOfActionResultOfSequenceOfProducts))] @@ -1212,6 +1253,24 @@ public void GetApiDescription_IncludesRequestFormats_FilteredByAttribute() f => Assert.Equal("text/xml", f.MediaType.ToString())); } + [Fact] + public void GetApiDescription_IncludesRequestFormats_FilteredByAcceptsMetadata() + { + // Arrange + var action = CreateActionDescriptor(nameof(AcceptsProduct_Body)); + action.EndpointMetadata = new List() { new XmlOnlyMetadata() }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Collection( + description.SupportedRequestFormats.OrderBy(f => f.MediaType.ToString()), + f => Assert.Equal("application/xml", f.MediaType.ToString()), + f => Assert.Equal("text/xml", f.MediaType.ToString())); + } + [Fact] public void GetApiDescription_IncludesRequestFormats_FilteredByType() { @@ -1375,8 +1434,8 @@ public void GetApiDescription_ParameterDescription_SourceFromFormFile() var action = CreateActionDescriptor(nameof(AcceptsFormFile)); action.FilterDescriptors = new[] { - new FilterDescriptor(new ConsumesAttribute("multipart/form-data"), FilterScope.Action), - }; + new FilterDescriptor(new ConsumesAttribute("multipart/form-data"), FilterScope.Action), + }; // Act var descriptions = GetApiDescriptions(action); @@ -2244,10 +2303,12 @@ private Product ReturnsProduct() } private ActionResult ReturnsActionResultOfProduct() => null; + private Http.HttpResults.Ok ReturnsResultOfProductWithEndpointMetadata() => null; private ActionResult> ReturnsActionResultOfSequenceOfProducts() => null; private Task> ReturnsTaskOfActionResultOfProduct() => null; + private Task> ReturnsTaskOfResultOfProductWithEndpointMetadata() => null; private Task>> ReturnsTaskOfActionResultOfSequenceOfProducts() => null; @@ -2657,4 +2718,13 @@ private class FromFormFileAttribute : Attribute, IBindingSourceMetadata { public BindingSource BindingSource => BindingSource.FormFile; } + + private class XmlOnlyMetadata : Http.Metadata.IAcceptsMetadata + { + public IReadOnlyList ContentTypes => new[] { "text/xml", "application/xml" }; + + public Type RequestType => null; + + public bool IsOptional => false; + } } diff --git a/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs b/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs index d42b6b3f6ed7..09e0d633f395 100644 --- a/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs +++ b/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs @@ -23,6 +23,7 @@ public ApiBehaviorApplicationModelProvider( ActionModelConventions = new List() { new ApiVisibilityConvention(), + new EndpointMetadataConvention(serviceProvider) }; if (!options.SuppressMapClientErrors) diff --git a/src/Mvc/Mvc.Core/src/ApplicationModels/EndpointMetadataConvention.cs b/src/Mvc/Mvc.Core/src/ApplicationModels/EndpointMetadataConvention.cs new file mode 100644 index 000000000000..b830993746ab --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ApplicationModels/EndpointMetadataConvention.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels; + +internal sealed class EndpointMetadataConvention : IActionModelConvention +{ + private static readonly MethodInfo PopulateMetadataForEndpointMethod = typeof(EndpointMetadataConvention).GetMethod(nameof(PopulateMetadataForEndpoint), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo PopulateMetadataForParameterMethod = typeof(EndpointMetadataConvention).GetMethod(nameof(PopulateMetadataForParameter), BindingFlags.NonPublic | BindingFlags.Static)!; + private readonly IServiceProvider _serviceProvider; + + public EndpointMetadataConvention(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public void Apply(ActionModel action) + { + // Get metadata from parameter types + ApplyParametersMetadata(action); + + // Get metadata from return type + ApplyReturnTypeMetadata(action); + } + + private void ApplyReturnTypeMetadata(ActionModel action) + { + var returnType = action.ActionMethod.ReturnType; + if (AwaitableInfo.IsTypeAwaitable(returnType, out var awaitableInfo)) + { + returnType = awaitableInfo.ResultType; + } + + if (returnType is not null && typeof(IEndpointMetadataProvider).IsAssignableFrom(returnType)) + { + object?[]? invokeArgs = null; + + for (var i = 0; i < action.Selectors.Count; i++) + { + // Return type implements IEndpointMetadataProvider + var context = new EndpointMetadataContext(action.ActionMethod, action.Selectors[i].EndpointMetadata, _serviceProvider); + invokeArgs ??= new object[1]; + invokeArgs[0] = context; + PopulateMetadataForEndpointMethod.MakeGenericMethod(returnType).Invoke(null, invokeArgs); + } + } + } + + private void ApplyParametersMetadata(ActionModel action) + { + object?[]? invokeArgs = null; + var parameters = action.ActionMethod.GetParameters(); + + foreach (var parameter in parameters) + { + if (typeof(IEndpointParameterMetadataProvider).IsAssignableFrom(parameter.ParameterType)) + { + for (var i = 0; i < action.Selectors.Count; i++) + { + // Parameter type implements IEndpointParameterMetadataProvider + var context = new EndpointParameterMetadataContext(parameter, action.Selectors[i].EndpointMetadata, _serviceProvider); + invokeArgs ??= new object[1]; + invokeArgs[0] = context; + PopulateMetadataForParameterMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs); + } + } + + if (typeof(IEndpointMetadataProvider).IsAssignableFrom(parameter.ParameterType)) + { + for (var i = 0; i < action.Selectors.Count; i++) + { + // Return type implements IEndpointMetadataProvider + var context = new EndpointMetadataContext(action.ActionMethod, action.Selectors[i].EndpointMetadata, _serviceProvider); + invokeArgs ??= new object[1]; + invokeArgs[0] = context; + PopulateMetadataForEndpointMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs); + } + } + } + } + + private static void PopulateMetadataForParameter(EndpointParameterMetadataContext parameterContext) + where T : IEndpointParameterMetadataProvider + { + T.PopulateMetadata(parameterContext); + } + + private static void PopulateMetadataForEndpoint(EndpointMetadataContext context) + where T : IEndpointMetadataProvider + { + T.PopulateMetadata(context); + } +} diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs index 16117d17db96..fc4d24cba9d2 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs @@ -53,6 +53,7 @@ public void OnProvidersExecuting_AppliesConventions() var actionModel = new ActionModel(method, Array.Empty()) { Controller = controllerModel, + Selectors = { new SelectorModel { AttributeRouteModel = new AttributeRouteModel() } }, }; controllerModel.Actions.Add(actionModel); @@ -77,6 +78,8 @@ public void OnProvidersExecuting_AppliesConventions() Assert.NotEmpty(actionModel.Filters.OfType()); Assert.NotEmpty(actionModel.Filters.OfType()); Assert.Equal(BindingSource.Body, parameterModel.BindingInfo.BindingSource); + Assert.NotEmpty(actionModel.Selectors); + Assert.Empty(actionModel.Selectors[0].EndpointMetadata); } [Fact] @@ -93,6 +96,7 @@ public void OnProvidersExecuting_AppliesConventionsForIResult() var actionModel = new ActionModel(method, Array.Empty()) { Controller = controllerModel, + Selectors = { new SelectorModel { AttributeRouteModel = new AttributeRouteModel() } }, }; controllerModel.Actions.Add(actionModel); @@ -117,6 +121,8 @@ public void OnProvidersExecuting_AppliesConventionsForIResult() Assert.NotEmpty(actionModel.Filters.OfType()); Assert.NotEmpty(actionModel.Filters.OfType()); Assert.Equal(BindingSource.Body, parameterModel.BindingInfo.BindingSource); + Assert.NotEmpty(actionModel.Selectors); + Assert.Empty(actionModel.Selectors[0].EndpointMetadata); } [Fact] @@ -129,6 +135,7 @@ public void Constructor_SetsUpConventions() Assert.Collection( provider.ActionModelConventions, c => Assert.IsType(c), + c => Assert.IsType(c), c => Assert.IsType(c), c => Assert.IsType(c), c => Assert.IsType(c), diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs index 2a65b881229e..6098de9d0f36 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/ControllerActionDescriptorProviderTests.cs @@ -755,42 +755,27 @@ public void AttributeRouting_Name_ThrowsIfMultipleActions_WithDifferentTemplates // Arrange var sameNameType = typeof(SameNameDifferentTemplatesController).GetTypeInfo(); var provider = GetProvider(sameNameType); - var assemblyName = sameNameType.Assembly.GetName().Name; - var expectedMessage = - "The following errors occurred with attribute routing information:" - + Environment.NewLine + Environment.NewLine + - "Error 1:" + Environment.NewLine + - "Attribute routes with the same name 'Products' must have the same template:" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.Get ({assemblyName})' - Template: 'Products'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.Get ({assemblyName})' - Template: 'Products/{{id}}'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.Put ({assemblyName})' - Template: 'Products/{{id}}'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.Post ({assemblyName})' - Template: 'Products'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.Delete ({assemblyName})' - Template: 'Products/{{id}}'" - + Environment.NewLine + Environment.NewLine + - "Error 2:" + Environment.NewLine + - "Attribute routes with the same name 'Items' must have the same template:" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.GetItems ({assemblyName})' - Template: 'Items/{{id}}'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.PostItems ({assemblyName})' - Template: 'Items'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.PutItems ({assemblyName})' - Template: 'Items/{{id}}'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.DeleteItems ({assemblyName})' - Template: 'Items/{{id}}'" - + Environment.NewLine + - $"Action: '{sameNameType.FullName}.PatchItems ({assemblyName})' - Template: 'Items'"; // Act var ex = Assert.Throws(() => { provider.GetDescriptors(); }); // Assert - Assert.Equal(expectedMessage, ex.Message); + Assert.Contains("The following errors occurred with attribute routing information:", ex.Message); + Assert.Contains("Error 1:", ex.Message); + Assert.Contains("Attribute routes with the same name 'Products' must have the same template:", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.Get ({assemblyName})' - Template: 'Products'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.Get ({assemblyName})' - Template: 'Products/{{id}}'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.Put ({assemblyName})' - Template: 'Products/{{id}}'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.Post ({assemblyName})' - Template: 'Products'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.Delete ({assemblyName})' - Template: 'Products/{{id}}'", ex.Message); + Assert.Contains("Error 2:", ex.Message); + Assert.Contains("Attribute routes with the same name 'Items' must have the same template:", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.GetItems ({assemblyName})' - Template: 'Items/{{id}}'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.PostItems ({assemblyName})' - Template: 'Items'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.PutItems ({assemblyName})' - Template: 'Items/{{id}}'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.DeleteItems ({assemblyName})' - Template: 'Items/{{id}}'", ex.Message); + Assert.Contains($"Action: '{sameNameType.FullName}.PatchItems ({assemblyName})' - Template: 'Items'", ex.Message); } [Fact] diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs new file mode 100644 index 000000000000..f0a483355fcd --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs @@ -0,0 +1,385 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels; + +public class EndpointMetadataConventionTest +{ + [Theory] + [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInValueTaskOfResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInValueTaskOfActionResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInTaskOfResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInTaskOfActionResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithMetadataInActionResult))] + public void Apply_DiscoversEndpointMetadata_FromReturnTypeImplementingIEndpointMetadataProvider( + Type controllerType, + string actionName) + { + // Arrange + var action = GetActionModel(controllerType, actionName); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.Contains(action.Selectors[0].EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.ReturnType }); + } + + [Fact] + public void Apply_DiscoversEndpointMetadata_ForAllSelectors_FromReturnTypeImplementingIEndpointMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.MultipleSelectorsActionWithMetadataInActionResult)); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + foreach (var selector in action.Selectors) + { + Assert.Contains(selector.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.ReturnType }); + } + } + + [Fact] + public void Apply_DiscoversMetadata_FromParametersImplementingIEndpointParameterMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.ActionWithParameterMetadata)); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.Contains(action.Selectors[0].EndpointMetadata, m => m is ParameterNameMetadata { Name: "param1" }); + } + + [Fact] + public void Apply_DiscoversEndpointMetadata_ForAllSelectors_FromParametersImplementingIEndpointParameterMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.MultipleSelectorsActionWithParameterMetadata)); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + foreach (var selector in action.Selectors) + { + Assert.Contains(selector.EndpointMetadata, m => m is ParameterNameMetadata { Name: "param1" }); + } + } + + [Fact] + public void Apply_DiscoversMetadata_FromParametersImplementingIEndpointMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.ActionWithParameterMetadata)); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.Contains(action.Selectors[0].EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Parameter }); + } + + [Fact] + public void Apply_DiscoversEndpointMetadata_ForAllSelectors_FromParametersImplementingIEndpointMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.MultipleSelectorsActionWithParameterMetadata)); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + foreach (var selector in action.Selectors) + { + Assert.Contains(selector.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Parameter }); + } + } + + [Fact] + public void Apply_DiscoversMetadata_CorrectOrder() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.ActionWithParameterMetadata)); + action.Selectors[0].EndpointMetadata.Add(new CustomEndpointMetadata() { Source = MetadataSource.Caller }); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.Collection( + action.Selectors[0].EndpointMetadata, + m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller }), + m => Assert.True(m is ParameterNameMetadata { Name: "param1" }), + m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter })); + } + + [Theory] + [InlineData(typeof(TestController), nameof(TestController.ActionWithNoAcceptsMetadataInValueTaskOfResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithNoAcceptsMetadataInValueTaskOfActionResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithNoAcceptsMetadataInTaskOfResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithNoAcceptsMetadataInTaskOfActionResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithNoAcceptsMetadataInResult))] + [InlineData(typeof(TestController), nameof(TestController.ActionWithNoAcceptsMetadataInActionResult))] + public void Apply_AllowsRemovalOfMetadata_ByReturnTypeImplementingIEndpointMetadataProvider( + Type controllerType, + string actionName) + { + // Arrange + var action = GetActionModel(controllerType, actionName); + action.Selectors[0].EndpointMetadata.Add(new ConsumesAttribute("application/json")); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.DoesNotContain(action.Selectors[0].EndpointMetadata, m => m is IAcceptsMetadata); + } + + [Fact] + public void Apply_AllowsRemovalOfMetadata_ByParameterTypeImplementingIEndpointMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.ActionWithRemovalFromParameterEndpointMetadata)); + action.Selectors[0].EndpointMetadata.Add(new ConsumesAttribute("application/json")); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.DoesNotContain(action.Selectors[0].EndpointMetadata, m => m is IAcceptsMetadata); + } + + [Fact] + public void Apply_AllowsRemovalOfMetadata_ByParameterTypeImplementingIEndpointParameterMetadataProvider() + { + // Arrange + var action = GetActionModel(typeof(TestController), nameof(TestController.ActionWithRemovalFromParameterMetadata)); + action.Selectors[0].EndpointMetadata.Add(new ConsumesAttribute("application/json")); + var convention = GetConvention(); + + //Act + convention.Apply(action); + + // Assert + Assert.DoesNotContain(action.Selectors[0].EndpointMetadata, m => m is IAcceptsMetadata); + } + + private static EndpointMetadataConvention GetConvention(IServiceProvider services = null) + { + services ??= Mock.Of(); + return new EndpointMetadataConvention(services); + } + + private static ApplicationModelProviderContext GetContext(Type type) + { + var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() }); + var mvcOptions = Options.Create(new MvcOptions()); + var convention = new DefaultApplicationModelProvider(mvcOptions, new EmptyModelMetadataProvider()); + convention.OnProvidersExecuting(context); + + return context; + } + + private static ActionModel GetActionModel( + Type controllerType, + string actionName) + { + var context = GetContext(controllerType); + var controller = Assert.Single(context.Result.Controllers); + return Assert.Single(controller.Actions, m => m.ActionName == actionName); + } + + private class TestController + { + public ActionResult ActionWithParameterMetadata(AddsCustomParameterMetadata param1) => null; + public ActionResult ActionWithRemovalFromParameterMetadata(RemovesAcceptsParameterMetadata param1) => null; + public ActionResult ActionWithRemovalFromParameterEndpointMetadata(RemovesAcceptsParameterEndpointMetadata param1) => null; + + [HttpGet("selector1")] + [HttpGet("selector2")] + public ActionResult MultipleSelectorsActionWithParameterMetadata(AddsCustomParameterMetadata param1) => null; + + public AddsCustomEndpointMetadataResult ActionWithMetadataInResult() => null; + + public ValueTask ActionWithMetadataInValueTaskOfResult() + => ValueTask.FromResult(null); + + public Task ActionWithMetadataInTaskOfResult() + => Task.FromResult(null); + + [HttpGet("selector1")] + [HttpGet("selector2")] + public AddsCustomEndpointMetadataActionResult MultipleSelectorsActionWithMetadataInActionResult() => null; + + public AddsCustomEndpointMetadataActionResult ActionWithMetadataInActionResult() => null; + + public ValueTask ActionWithMetadataInValueTaskOfActionResult() + => ValueTask.FromResult(null); + + public Task ActionWithMetadataInTaskOfActionResult() + => Task.FromResult(null); + + public RemovesAcceptsMetadataResult ActionWithNoAcceptsMetadataInResult() => null; + + public ValueTask ActionWithNoAcceptsMetadataInValueTaskOfResult() + => ValueTask.FromResult(null); + + public Task ActionWithNoAcceptsMetadataInTaskOfResult() + => Task.FromResult(null); + + public RemovesAcceptsMetadataActionResult ActionWithNoAcceptsMetadataInActionResult() => null; + + public ValueTask ActionWithNoAcceptsMetadataInValueTaskOfActionResult() + => ValueTask.FromResult(null); + + public Task ActionWithNoAcceptsMetadataInTaskOfActionResult() + => Task.FromResult(null); + } + + private class CustomEndpointMetadata + { + public string Data { get; init; } + + public MetadataSource Source { get; init; } + } + private enum MetadataSource + { + Caller, + Parameter, + ReturnType + } + + private class ParameterNameMetadata + { + public string Name { get; init; } + } + + private class AddsCustomParameterMetadata : IEndpointParameterMetadataProvider, IEndpointMetadataProvider + { + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) + { + parameterContext.EndpointMetadata?.Add(new ParameterNameMetadata { Name = parameterContext.Parameter?.Name }); + } + + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata?.Add(new CustomEndpointMetadata { Source = MetadataSource.Parameter }); + } + } + + private class AddsCustomEndpointMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata?.Add(new CustomEndpointMetadata { Source = MetadataSource.ReturnType }); + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class AddsCustomEndpointMetadataActionResult : IEndpointMetadataProvider, IActionResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata?.Add(new CustomEndpointMetadata { Source = MetadataSource.ReturnType }); + } + public Task ExecuteResultAsync(ActionContext context) => throw new NotImplementedException(); + } + + private class RemovesAcceptsMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + if (context.EndpointMetadata is not null) + { + for (int i = context.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = context.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + context.EndpointMetadata.RemoveAt(i); + } + } + } + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class RemovesAcceptsMetadataActionResult : IEndpointMetadataProvider, IActionResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + if (context.EndpointMetadata is not null) + { + for (int i = context.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = context.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + context.EndpointMetadata.RemoveAt(i); + } + } + } + } + + public Task ExecuteResultAsync(ActionContext context) => throw new NotImplementedException(); + } + + private class RemovesAcceptsParameterMetadata : IEndpointParameterMetadataProvider + { + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) + { + if (parameterContext.EndpointMetadata is not null) + { + for (int i = parameterContext.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = parameterContext.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + parameterContext.EndpointMetadata.RemoveAt(i); + } + } + } + } + } + + private class RemovesAcceptsParameterEndpointMetadata : IEndpointMetadataProvider + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + if (context.EndpointMetadata is not null) + { + for (int i = context.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = context.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + context.EndpointMetadata.RemoveAt(i); + } + } + } + } + } +}