diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
index 748965a360a6..65576652a378 100644
--- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
+++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj
@@ -23,6 +23,7 @@
+
diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
index d86e3a48a820..2faaf9c60984 100644
--- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
+++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
@@ -17,6 +17,7 @@
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Http.Metadata;
+using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Internal;
@@ -260,12 +261,12 @@ public static RequestDelegateResult Create(MethodInfo methodInfo, Func task, HttpContext httpContext, Jso
private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo
diff --git a/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs b/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs
new file mode 100644
index 000000000000..5f6fd3ffaf33
--- /dev/null
+++ b/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs
@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Text.Json.Serialization.Metadata;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Json;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Routing;
+
+internal static class RequestDelegateFilterPipelineBuilder
+{
+ // Due to https://github.com/dotnet/aspnetcore/issues/41330 we cannot reference the EmptyHttpResult type
+ // but users still need to assert on it as in https://github.com/dotnet/aspnetcore/issues/45063
+ // so we temporarily work around this here by using reflection to get the actual type.
+ private static readonly object? EmptyHttpResultInstance = Type.GetType("Microsoft.AspNetCore.Http.HttpResults.EmptyHttpResult, Microsoft.AspNetCore.Http.Results")?.GetProperty("Instance")?.GetValue(null, null);
+
+ public static RequestDelegate Create(RequestDelegate requestDelegate, RequestDelegateFactoryOptions options)
+ {
+ Debug.Assert(options.EndpointBuilder != null);
+
+ var serviceProvider = options.ServiceProvider ?? options.EndpointBuilder.ApplicationServices;
+ var jsonOptions = serviceProvider?.GetService>()?.Value ?? new JsonOptions();
+ var jsonSerializerOptions = jsonOptions.SerializerOptions;
+
+ var factoryContext = new EndpointFilterFactoryContext
+ {
+ MethodInfo = requestDelegate.Method,
+ ApplicationServices = options.EndpointBuilder.ApplicationServices
+ };
+ var jsonTypeInfo = (JsonTypeInfo)jsonSerializerOptions.GetReadOnlyTypeInfo(typeof(object));
+
+ EndpointFilterDelegate filteredInvocation = async (EndpointFilterInvocationContext context) =>
+ {
+ Debug.Assert(EmptyHttpResultInstance != null, "Unable to get EmptyHttpResult instance via reflection.");
+ if (context.HttpContext.Response.StatusCode < 400)
+ {
+ await requestDelegate(context.HttpContext);
+ }
+ return EmptyHttpResultInstance;
+ };
+
+ var initialFilteredInvocation = filteredInvocation;
+ for (var i = options.EndpointBuilder.FilterFactories.Count - 1; i >= 0; i--)
+ {
+ var currentFilterFactory = options.EndpointBuilder.FilterFactories[i];
+ filteredInvocation = currentFilterFactory(factoryContext, filteredInvocation);
+ }
+
+ // The filter factories have run without modifying per-request behavior, we can skip running the pipeline.
+ if (ReferenceEquals(initialFilteredInvocation, filteredInvocation))
+ {
+ return requestDelegate;
+ }
+
+ return async (HttpContext httpContext) =>
+ {
+ var obj = await filteredInvocation(new DefaultEndpointFilterInvocationContext(httpContext, new object[] { httpContext }));
+ if (obj is not null)
+ {
+ await ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, jsonSerializerOptions, jsonTypeInfo);
+ }
+ };
+ }
+}
diff --git a/src/Http/Routing/src/RouteEndpointDataSource.cs b/src/Http/Routing/src/RouteEndpointDataSource.cs
index 1bacad5cd6f0..96fbdf6c6cb1 100644
--- a/src/Http/Routing/src/RouteEndpointDataSource.cs
+++ b/src/Http/Routing/src/RouteEndpointDataSource.cs
@@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Builder;
@@ -28,9 +27,9 @@ public RouteEndpointDataSource(IServiceProvider applicationServices, bool throwO
public RouteHandlerBuilder AddRequestDelegate(
RoutePattern pattern,
RequestDelegate requestDelegate,
- IEnumerable? httpMethods)
+ IEnumerable? httpMethods,
+ Func createHandlerRequestDelegateFunc)
{
-
var conventions = new ThrowOnAddAfterEndpointBuiltConventionCollection();
var finallyConventions = new ThrowOnAddAfterEndpointBuiltConventionCollection();
@@ -41,7 +40,9 @@ public RouteHandlerBuilder AddRequestDelegate(
HttpMethods = httpMethods,
RouteAttributes = RouteAttributes.None,
Conventions = conventions,
- FinallyConventions = finallyConventions
+ FinallyConventions = finallyConventions,
+ InferMetadataFunc = null, // Metadata isn't infered from RequestDelegate endpoints
+ CreateHandlerRequestDelegateFunc = createHandlerRequestDelegateFunc
});
return new RouteHandlerBuilder(conventions, finallyConventions);
@@ -51,7 +52,9 @@ public RouteHandlerBuilder AddRouteHandler(
RoutePattern pattern,
Delegate routeHandler,
IEnumerable? httpMethods,
- bool isFallback)
+ bool isFallback,
+ Func? inferMetadataFunc,
+ Func createHandlerRequestDelegateFunc)
{
var conventions = new ThrowOnAddAfterEndpointBuiltConventionCollection();
var finallyConventions = new ThrowOnAddAfterEndpointBuiltConventionCollection();
@@ -69,7 +72,9 @@ public RouteHandlerBuilder AddRouteHandler(
HttpMethods = httpMethods,
RouteAttributes = routeAttributes,
Conventions = conventions,
- FinallyConventions = finallyConventions
+ FinallyConventions = finallyConventions,
+ InferMetadataFunc = inferMetadataFunc,
+ CreateHandlerRequestDelegateFunc = createHandlerRequestDelegateFunc
});
return new RouteHandlerBuilder(conventions, finallyConventions);
@@ -196,8 +201,10 @@ private RouteEndpointBuilder CreateRouteEndpointBuilder(
// they can do so via IEndpointConventionBuilder.Finally like the do to override any other entry-specific metadata.
if (isRouteHandler)
{
+ Debug.Assert(entry.InferMetadataFunc != null, "A func to infer metadata must be provided for route handlers.");
+
rdfOptions = CreateRdfOptions(entry, pattern, builder);
- rdfMetadataResult = InferHandlerMetadata(entry.RouteHandler.Method, rdfOptions);
+ rdfMetadataResult = entry.InferMetadataFunc(entry.RouteHandler.Method, rdfOptions);
}
// Add delegate attributes as metadata before entry-specific conventions but after group conventions.
@@ -225,7 +232,7 @@ private RouteEndpointBuilder CreateRouteEndpointBuilder(
// We ignore the returned EndpointMetadata has been already populated since we passed in non-null EndpointMetadata.
// We always set factoryRequestDelegate in case something is still referencing the redirected version of the RequestDelegate.
- factoryCreatedRequestDelegate = CreateHandlerRequestDelegate(entry.RouteHandler, rdfOptions, rdfMetadataResult);
+ factoryCreatedRequestDelegate = entry.CreateHandlerRequestDelegateFunc(entry.RouteHandler, rdfOptions, rdfMetadataResult).RequestDelegate;
}
Debug.Assert(factoryCreatedRequestDelegate is not null);
@@ -251,28 +258,6 @@ private RouteEndpointBuilder CreateRouteEndpointBuilder(
}
return builder;
-
- [UnconditionalSuppressMessage("Trimmer", "IL2026",
- Justification = "We surface a RequireUnreferencedCode in the call to the Map methods adding route handlers to this EndpointDataSource. Analysis is unable to infer this. " +
- "Map methods that configure a RequestDelegate don't use trimmer unsafe features.")]
- [UnconditionalSuppressMessage("AOT", "IL3050",
- Justification = "We surface a RequiresDynamicCode in the call to the Map methods adding route handlers this EndpointDataSource. Analysis is unable to infer this. " +
- "Map methods that configure a RequestDelegate don't use AOT unsafe features.")]
- static RequestDelegateMetadataResult InferHandlerMetadata(MethodInfo methodInfo, RequestDelegateFactoryOptions? options = null)
- {
- return RequestDelegateFactory.InferMetadata(methodInfo, options);
- }
-
- [UnconditionalSuppressMessage("Trimmer", "IL2026",
- Justification = "We surface a RequireUnreferencedCode in the call to the Map methods adding route handlers to this EndpointDataSource. Analysis is unable to infer this. " +
- "Map methods that configure a RequestDelegate don't use trimmer unsafe features.")]
- [UnconditionalSuppressMessage("AOT", "IL3050",
- Justification = "We surface a RequiresDynamicCode in the call to the Map methods adding route handlers this EndpointDataSource. Analysis is unable to infer this. " +
- "Map methods that configure a RequestDelegate don't use AOT unsafe features.")]
- static RequestDelegate CreateHandlerRequestDelegate(Delegate handler, RequestDelegateFactoryOptions options, RequestDelegateMetadataResult? metadataResult)
- {
- return RequestDelegateFactory.Create(handler, options, metadataResult).RequestDelegate;
- }
}
private RequestDelegateFactoryOptions CreateRdfOptions(RouteEntry entry, RoutePattern pattern, RouteEndpointBuilder builder)
@@ -323,14 +308,16 @@ static bool ShouldDisableInferredBodyForMethod(string method) =>
return false;
}
- private struct RouteEntry
+ private readonly struct RouteEntry
{
- public RoutePattern RoutePattern { get; init; }
- public Delegate RouteHandler { get; init; }
- public IEnumerable? HttpMethods { get; init; }
- public RouteAttributes RouteAttributes { get; init; }
- public ThrowOnAddAfterEndpointBuiltConventionCollection Conventions { get; init; }
- public ThrowOnAddAfterEndpointBuiltConventionCollection FinallyConventions { get; init; }
+ public required RoutePattern RoutePattern { get; init; }
+ public required Delegate RouteHandler { get; init; }
+ public required IEnumerable? HttpMethods { get; init; }
+ public required RouteAttributes RouteAttributes { get; init; }
+ public required ThrowOnAddAfterEndpointBuiltConventionCollection Conventions { get; init; }
+ public required ThrowOnAddAfterEndpointBuiltConventionCollection FinallyConventions { get; init; }
+ public required Func? InferMetadataFunc { get; init; }
+ public required Func CreateHandlerRequestDelegateFunc { get; init; }
}
[Flags]
diff --git a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs
index 0c693a01a2dd..c4924422a748 100644
--- a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs
+++ b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs
@@ -7,9 +7,11 @@
using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Builder;
@@ -29,28 +31,28 @@ public static object[][] MapMethods
{
get
{
- IEndpointConventionBuilder MapGet(IEndpointRouteBuilder routes, string template, Delegate action) =>
+ IEndpointConventionBuilder MapGet(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
routes.MapGet(template, action);
- IEndpointConventionBuilder MapPost(IEndpointRouteBuilder routes, string template, Delegate action) =>
+ IEndpointConventionBuilder MapPost(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
routes.MapPost(template, action);
- IEndpointConventionBuilder MapPut(IEndpointRouteBuilder routes, string template, Delegate action) =>
+ IEndpointConventionBuilder MapPut(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
routes.MapPut(template, action);
- IEndpointConventionBuilder MapDelete(IEndpointRouteBuilder routes, string template, Delegate action) =>
+ IEndpointConventionBuilder MapDelete(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
routes.MapDelete(template, action);
- IEndpointConventionBuilder Map(IEndpointRouteBuilder routes, string template, Delegate action) =>
+ IEndpointConventionBuilder Map(IEndpointRouteBuilder routes, string template, RequestDelegate action) =>
routes.Map(template, action);
return new object[][]
{
- new object[] { (Func)MapGet },
- new object[] { (Func)MapPost },
- new object[] { (Func)MapPut },
- new object[] { (Func)MapDelete },
- new object[] { (Func)Map },
+ new object[] { (Func)MapGet },
+ new object[] { (Func)MapPost },
+ new object[] { (Func)MapPut },
+ new object[] { (Func)MapDelete },
+ new object[] { (Func)Map },
};
}
}
@@ -75,7 +77,7 @@ public void MapEndpoint_StringPattern_BuildsEndpoint()
[Theory]
[MemberData(nameof(MapMethods))]
- public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func map)
+ public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func map)
{
var httpContext = new DefaultHttpContext();
var responseBodyStream = new MemoryStream();
@@ -83,7 +85,11 @@ public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func GenericTypeTaskDelegate(HttpContext context) => await Task.FromResult("String Test");
+ static async Task GenericTypeTaskDelegate(HttpContext context)
+ {
+ await context.Response.WriteAsync("Response string text");
+ return await Task.FromResult("String Test");
+ }
// Act
var endpointBuilder = map(builder, "/", GenericTypeTaskDelegate);
@@ -98,12 +104,12 @@ public async Task MapEndpoint_ReturnGenericTypeTask_GeneratedDelegate(Func map)
+ public async Task MapEndpoint_CanBeFiltered_EndpointFilterFactory(Func map)
{
var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
var httpContext = new DefaultHttpContext();
@@ -111,7 +117,6 @@ public async Task MapEndpoint_CanBeFiltered_ByEndpointFilters(Func Task.CompletedTask;
- var filterTag = new TagsAttribute("filter");
var endpointBuilder = map(builder, "/", initialRequestDelegate).AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) =>
{
@@ -119,7 +124,7 @@ public async Task MapEndpoint_CanBeFiltered_ByEndpointFilters(Func(Assert.Single(invocationContext.Arguments));
// Ignore thre result and write filtered because we can!
- await next(invocationContext);
+ _ = await next(invocationContext);
return "filtered!";
};
});
@@ -138,6 +143,185 @@ public async Task MapEndpoint_CanBeFiltered_ByEndpointFilters(Func Task.CompletedTask;
+
+ var endpointBuilder = builder.Map("/", initialRequestDelegate)
+ .AddEndpointFilter(new HttpContextArgFilter("First"))
+ .AddEndpointFilter(new HttpContextArgFilter("Second"));
+
+ var dataSource = GetBuilderEndpointDataSource(builder);
+ var endpoint = Assert.Single(dataSource.Endpoints);
+
+ Assert.NotSame(initialRequestDelegate, endpoint.RequestDelegate);
+
+ Assert.NotNull(endpoint.RequestDelegate);
+ var requestDelegate = endpoint.RequestDelegate!;
+ await requestDelegate(httpContext);
+
+ var responseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray());
+
+ Assert.Equal("filtered!", responseBody);
+ Assert.Equal(1, (int)httpContext.Items["First-Order"]!);
+ Assert.Equal(2, (int)httpContext.Items["Second-Order"]!);
+ }
+
+ [Fact]
+ public async Task MapEndpoint_Filtered_DontExecuteEndpointWhenErrorResponseStatus()
+ {
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
+ var httpContext = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ httpContext.Response.Body = responseBodyStream;
+
+ RequestDelegate initialRequestDelegate = static (context) =>
+ {
+ context.Items["ExecutedEndpoint"] = true;
+ throw new Exception("Shouldn't reach here.");
+ };
+
+ var endpointBuilder = builder.Map("/", initialRequestDelegate)
+ .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) =>
+ {
+ return async invocationContext =>
+ {
+ var httpContext = Assert.IsAssignableFrom(Assert.Single(invocationContext.Arguments));
+ httpContext.Items["First"] = true;
+ httpContext.Response.StatusCode = 400;
+ return await next(invocationContext);
+ };
+ })
+ .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) =>
+ {
+ return invocationContext =>
+ {
+ var httpContext = Assert.IsAssignableFrom(Assert.Single(invocationContext.Arguments));
+ httpContext.Items["Second"] = true;
+ return next(invocationContext);
+ };
+ });
+
+ var dataSource = GetBuilderEndpointDataSource(builder);
+ var endpoint = Assert.Single(dataSource.Endpoints);
+
+ Assert.NotSame(initialRequestDelegate, endpoint.RequestDelegate);
+
+ Assert.NotNull(endpoint.RequestDelegate);
+ var requestDelegate = endpoint.RequestDelegate!;
+ await requestDelegate(httpContext);
+
+ Assert.True((bool)httpContext.Items["First"]!);
+ Assert.True((bool)httpContext.Items["Second"]!);
+ Assert.False(httpContext.Items.ContainsKey("ExecutedEndpoint"));
+ }
+
+ [Fact]
+ public async Task RequestFilters_CanAssertOnEmptyResult()
+ {
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
+ var httpContext = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ httpContext.Response.Body = responseBodyStream;
+
+ var @delegate = (HttpContext context) => context.Items.Add("param", "Value");
+
+ object? response = null;
+ var endpointBuilder = builder.Map("/", @delegate)
+ .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) =>
+ {
+ return async invocationContext =>
+ {
+ response = await next(invocationContext);
+ return response;
+ };
+ });
+
+ var dataSource = GetBuilderEndpointDataSource(builder);
+ var endpoint = Assert.Single(dataSource.Endpoints);
+
+ httpContext.Request.Query = new QueryCollection(new Dictionary
+ {
+ ["name"] = "Tester"
+ });
+
+ await endpoint.RequestDelegate!(httpContext);
+
+ Assert.IsType(response);
+ Assert.Same(Results.Empty, response);
+ }
+
+ [Fact]
+ public async Task RequestFilters_ReturnValue_SerializeJson()
+ {
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
+ var httpContext = new DefaultHttpContext();
+ var responseBodyStream = new MemoryStream();
+ httpContext.Response.Body = responseBodyStream;
+
+ RequestDelegate requestDelegate = (HttpContext context) => Task.CompletedTask;
+
+ var endpointBuilder = builder.Map("/", requestDelegate)
+ .AddEndpointFilterFactory(filterFactory: (routeHandlerContext, next) =>
+ {
+ return async invocationContext =>
+ {
+ await next(invocationContext);
+ return new MyCoolType(Name: "你好"); // serialized as JSON
+ };
+ });
+
+ var dataSource = GetBuilderEndpointDataSource(builder);
+ var endpoint = Assert.Single(dataSource.Endpoints);
+
+ await endpoint.RequestDelegate!(httpContext);
+
+ var responseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray());
+ Assert.Equal(@"{""name"":""你好""}", responseBody);
+ }
+
+ private record struct MyCoolType(string Name);
+
+ private sealed class HttpContextArgFilter : IEndpointFilter
+ {
+ private readonly string _name;
+
+ public HttpContextArgFilter(string name)
+ {
+ _name = name;
+ }
+
+ public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
+ {
+ if (context.Arguments[0] is HttpContext httpContext)
+ {
+ int order;
+ if (httpContext.Items["CurrentOrder"] is int)
+ {
+ order = (int)httpContext.Items["CurrentOrder"]!;
+ order++;
+ httpContext.Items["CurrentOrder"] = order;
+ }
+ else
+ {
+ order = 1;
+ httpContext.Items["CurrentOrder"] = order;
+ }
+ httpContext.Items[$"{_name}-Order"] = order;
+ }
+
+ // Ignore thre result and write filtered because we can!
+ _ = await next(context);
+ return "filtered!";
+ }
+ }
+
[Theory]
[MemberData(nameof(MapMethods))]
public void MapEndpoint_UsesOriginalRequestDelegateInstance_IfFilterDoesNotChangePerRequestBehavior(Func map)
@@ -318,6 +502,22 @@ public void Map_AddsMetadata_InCorrectOrder()
m => Assert.IsAssignableFrom(m));
}
+ [Fact]
+ public void MapEndpoint_Filter()
+ {
+ // Arrange
+ var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(EmptyServiceProvider.Instance));
+
+ // Act
+ var endpointBuilder = builder
+ .Map(RoutePatternFactory.Parse("/"), context => Task.CompletedTask)
+ .AddEndpointFilter(new HttpContextArgFilter(""));
+
+ // Assert
+ var endpointBuilder1 = GetRouteEndpointBuilder(builder);
+ Assert.Equal("/", endpointBuilder1.RoutePattern.RawText);
+ }
+
[Attribute1]
[Attribute2]
private static Task Handle(HttpContext context) => Task.CompletedTask;
diff --git a/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj b/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj
index b184a2f3a01d..625d4361d03a 100644
--- a/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj
+++ b/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs
new file mode 100644
index 000000000000..7961796af105
--- /dev/null
+++ b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+using System.Text.Json.Serialization.Metadata;
+using System.Text.Json;
+
+namespace Microsoft.AspNetCore.Internal;
+
+internal static class ExecuteHandlerHelper
+{
+ public static Task ExecuteReturnAsync(object obj, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo)
+ {
+ // Terminal built ins
+ if (obj is IResult result)
+ {
+ return result.ExecuteAsync(httpContext);
+ }
+ else if (obj is string stringValue)
+ {
+ SetPlaintextContentType(httpContext);
+ return httpContext.Response.WriteAsync(stringValue);
+ }
+ else
+ {
+ // Otherwise, we JSON serialize when we reach the terminal state
+ return WriteJsonResponseAsync(httpContext.Response, obj, options, jsonTypeInfo);
+ }
+ }
+
+ public static void SetPlaintextContentType(HttpContext httpContext)
+ {
+ httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
+ }
+
+ public static Task WriteJsonResponseAsync(HttpResponse response, T? value, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo)
+ {
+ var runtimeType = value?.GetType();
+
+ if (runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.IsPolymorphicSafe())
+ {
+ // In this case the polymorphism is not
+ // relevant for us and will be handled by STJ, if needed.
+ return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, jsonTypeInfo, default);
+ }
+
+ // Call WriteAsJsonAsync() with the runtime type to serialize the runtime type rather than the declared type
+ // and avoid source generators issues.
+ // https://github.com/dotnet/aspnetcore/issues/43894
+ // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism
+ var runtimeTypeInfo = options.GetTypeInfo(runtimeType);
+ return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, runtimeTypeInfo, default);
+ }
+}