From f07ff5d7a53f163f693dbfb34879b73b694eaf17 Mon Sep 17 00:00:00 2001 From: aw0lid Date: Thu, 30 Jul 2026 00:33:24 +0300 Subject: [PATCH] Mvc: Add SkipStatusCodePages feature --- src/Mvc/Mvc.Core/src/ApiBehaviorOptions.cs | 6 ++ .../ApiBehaviorApplicationModelProvider.cs | 31 +++++-- src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt | 2 + ...ApiBehaviorApplicationModelProviderTest.cs | 87 +++++++++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/Mvc/Mvc.Core/src/ApiBehaviorOptions.cs b/src/Mvc/Mvc.Core/src/ApiBehaviorOptions.cs index 8b18b7928d7f..8fcce4408f2c 100644 --- a/src/Mvc/Mvc.Core/src/ApiBehaviorOptions.cs +++ b/src/Mvc/Mvc.Core/src/ApiBehaviorOptions.cs @@ -81,6 +81,12 @@ public Func InvalidModelStateResponseFactory /// public bool SuppressMapClientErrors { get; set; } + /// + /// Gets or sets a value that determines if the status code pages middleware should be skipped + /// for controllers annotated with . + /// + public bool SkipStatusCodePages { get; set; } + /// /// Gets a map of HTTP status codes to . Configured values /// are used to transform to an diff --git a/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs b/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs index d42b6b3f6ed7..5af1fb2bdd4d 100644 --- a/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs +++ b/src/Mvc/Mvc.Core/src/ApplicationModels/ApiBehaviorApplicationModelProvider.cs @@ -8,46 +8,49 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Http.Metadata; namespace Microsoft.AspNetCore.Mvc.ApplicationModels; internal sealed class ApiBehaviorApplicationModelProvider : IApplicationModelProvider { + private readonly ApiBehaviorOptions _options; + public ApiBehaviorApplicationModelProvider( IOptions apiBehaviorOptions, IModelMetadataProvider modelMetadataProvider, IServiceProvider serviceProvider) { - var options = apiBehaviorOptions.Value; + _options = apiBehaviorOptions.Value; ActionModelConventions = new List() { new ApiVisibilityConvention(), }; - if (!options.SuppressMapClientErrors) + if (!_options.SuppressMapClientErrors) { ActionModelConventions.Add(new ClientErrorResultFilterConvention()); } - if (!options.SuppressModelStateInvalidFilter) + if (!_options.SuppressModelStateInvalidFilter) { ActionModelConventions.Add(new InvalidModelStateFilterConvention()); } - if (!options.SuppressConsumesConstraintForFormFileParameters) + if (!_options.SuppressConsumesConstraintForFormFileParameters) { ActionModelConventions.Add(new ConsumesConstraintForFormFileParameterConvention()); } - var defaultErrorType = options.SuppressMapClientErrors ? typeof(void) : typeof(ProblemDetails); + var defaultErrorType = _options.SuppressMapClientErrors ? typeof(void) : typeof(ProblemDetails); var defaultErrorTypeAttribute = new ProducesErrorResponseTypeAttribute(defaultErrorType); ActionModelConventions.Add(new ApiConventionApplicationModelConvention(defaultErrorTypeAttribute)); - if (!options.SuppressInferBindingSourcesForParameters) + if (!_options.SuppressInferBindingSourcesForParameters) { var serviceProviderIsService = serviceProvider.GetService(); - var convention = options.DisableImplicitFromServicesParameters || serviceProviderIsService is null ? + var convention = _options.DisableImplicitFromServicesParameters || serviceProviderIsService is null ? new InferParameterBindingInfoConvention(modelMetadataProvider) : new InferParameterBindingInfoConvention(modelMetadataProvider, serviceProviderIsService); ActionModelConventions.Add(convention); @@ -80,6 +83,14 @@ public void OnProvidersExecuting(ApplicationModelProviderContext context) // Ensure ApiController is set up correctly EnsureActionIsAttributeRouted(action); + if (_options.SkipStatusCodePages) + { + foreach (var selector in action.Selectors) + { + selector.EndpointMetadata.Add(SkipStatusCodePagesMetadata.Instance); + } + } + foreach (var convention in ActionModelConventions) { convention.Apply(action); @@ -126,3 +137,9 @@ private static bool IsApiController(ControllerModel controller) return assemblyAttributes.OfType().Any(); } } + +internal sealed class SkipStatusCodePagesMetadata : ISkipStatusCodePagesMetadata +{ + public static readonly SkipStatusCodePagesMetadata Instance = new(); + private SkipStatusCodePagesMetadata() { } +} diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index aff0c7c0f51c..30cdbb66c742 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -16,3 +16,5 @@ *REMOVED*static Microsoft.Extensions.DependencyInjection.MvcCoreMvcBuilderExtensions.SetCompatibilityVersion(this Microsoft.Extensions.DependencyInjection.IMvcBuilder! builder, Microsoft.AspNetCore.Mvc.CompatibilityVersion version) -> Microsoft.Extensions.DependencyInjection.IMvcBuilder! *REMOVED*static Microsoft.Extensions.DependencyInjection.MvcCoreMvcCoreBuilderExtensions.SetCompatibilityVersion(this Microsoft.Extensions.DependencyInjection.IMvcCoreBuilder! builder, Microsoft.AspNetCore.Mvc.CompatibilityVersion version) -> Microsoft.Extensions.DependencyInjection.IMvcCoreBuilder! *REMOVED*virtual Microsoft.AspNetCore.Mvc.Infrastructure.ConfigureCompatibilityOptions.PostConfigure(string? name, TOptions! options) -> void +Microsoft.AspNetCore.Mvc.ApiBehaviorOptions.SkipStatusCodePages.get -> bool +Microsoft.AspNetCore.Mvc.ApiBehaviorOptions.SkipStatusCodePages.set -> void diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs index 201fdf8bf153..ba791dfb8c2c 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs @@ -123,6 +123,61 @@ public void OnProvidersExecuting_AppliesConventionsForIResult() Assert.Empty(actionModel.Selectors[0].EndpointMetadata); } + [Fact] + public void OnProvidersExecuting_AddsSkipStatusCodePagesMetadata_WhenOptionIsTrueAndControllerIsApiController() + { + // Arrange + var (controllerModel, actionModel) = CreateControllerModel(typeof(TestApiController), isApiController: true); + var context = CreateContext(controllerModel); + var provider = GetProvider(new ApiBehaviorOptions + { + SkipStatusCodePages = true, + }); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var selector = Assert.Single(actionModel.Selectors); + var metadata = Assert.Single(selector.EndpointMetadata.OfType()); + Assert.Same(SkipStatusCodePagesMetadata.Instance, metadata); + } + + [Fact] + public void OnProvidersExecuting_DoesNotAddSkipStatusCodePagesMetadata_WhenOptionIsTrueAndControllerIsNotApiController() + { + // Arrange + var (controllerModel, actionModel) = CreateControllerModel(typeof(TestMvcController), isApiController: false); + var context = CreateContext(controllerModel); + var provider = GetProvider(new ApiBehaviorOptions + { + SkipStatusCodePages = true, + }); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var selector = Assert.Single(actionModel.Selectors); + Assert.Empty(selector.EndpointMetadata); + } + + [Fact] + public void OnProvidersExecuting_DoesNotAddSkipStatusCodePagesMetadata_WhenOptionIsFalseAndControllerIsApiController() + { + // Arrange + var (controllerModel, actionModel) = CreateControllerModel(typeof(TestApiController), isApiController: true); + var context = CreateContext(controllerModel); + var provider = GetProvider(new ApiBehaviorOptions()); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var selector = Assert.Single(actionModel.Selectors); + Assert.Empty(selector.EndpointMetadata); + } + [Fact] public void Constructor_SetsUpConventions() { @@ -221,9 +276,41 @@ private static ApiBehaviorApplicationModelProvider GetProvider( Mock.Of()); } + private static ApplicationModelProviderContext CreateContext(ControllerModel controllerModel) + { + var context = new ApplicationModelProviderContext(new[] { controllerModel.ControllerType }); + context.Result.Controllers.Add(controllerModel); + return context; + } + + private static (ControllerModel ControllerModel, ActionModel ActionModel) CreateControllerModel( + Type controllerType, + bool isApiController) + { + var controllerAttributes = isApiController ? new object[] { new ApiControllerAttribute() } : Array.Empty(); + var controllerModel = new ControllerModel(controllerType.GetTypeInfo(), controllerAttributes) + { + Selectors = { new SelectorModel { AttributeRouteModel = new AttributeRouteModel() } }, + }; + + var actionModel = new ActionModel(controllerType.GetMethod(nameof(TestApiController.TestAction)), Array.Empty()) + { + Controller = controllerModel, + Selectors = { new SelectorModel { AttributeRouteModel = new AttributeRouteModel() } }, + }; + controllerModel.Actions.Add(actionModel); + + return (controllerModel, actionModel); + } + private class TestApiController : ControllerBase { public IActionResult TestAction(object value) => null; public IResult TestActionWithIResult(object value) => null; } + + private class TestMvcController : ControllerBase + { + public IActionResult TestAction(object value) => null; + } }