-
Notifications
You must be signed in to change notification settings - Fork 10.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Generate route handler-endpoints at compile time via source generator #45524
Comments
Thanks for contacting us. We're moving this issue to the |
For the curious, here's an example of what the code emitted by the generator looks like: Generated Code//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using System.IO;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
using MetadataPopulator = System.Action<System.Delegate, Microsoft.AspNetCore.Builder.EndpointBuilder>;
using RequestDelegateFactoryFunc = System.Func<System.Delegate, Microsoft.AspNetCore.Builder.EndpointBuilder, Microsoft.AspNetCore.Http.RequestDelegate>;
namespace Microsoft.AspNetCore.Builder
{
internal class SourceKey
{
public string Path { get; init; }
public int Line { get; init; }
public SourceKey(string path, int line)
{
Path = path;
Line = line;
}
}
}
internal static class GeneratedRouteBuilderExtensions
{
private static readonly string[] GetVerb = new[] { HttpMethods.Get };
private static readonly string[] PostVerb = new[] { HttpMethods.Post };
private static readonly string[] PutVerb = new[] { HttpMethods.Put };
private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete };
private static readonly string[] PatchVerb = new[] { HttpMethods.Patch };
private static class GenericThunks<T>
{
public static readonly System.Collections.Generic.Dictionary<(string, int), (MetadataPopulator, RequestDelegateFactoryFunc)> map = new()
{
};
}
private static readonly System.Collections.Generic.Dictionary<(string, int), (MetadataPopulator, RequestDelegateFactoryFunc)> map = new()
{
[("TestMapActions.cs", 15)] = (
(del, builder) =>
{
builder.Metadata.Add(new SourceKey("TestMapActions.cs", 15));
},
(del, builder) =>
{
var handler = (System.Func<string>)del;
EndpointFilterDelegate? filteredInvocation = null;
if (builder.FilterFactories.Count > 0)
{
filteredInvocation = BuildFilterDelegate(ic =>
{
if (ic.HttpContext.Response.StatusCode == 400)
{
return System.Threading.Tasks.ValueTask.FromResult<object?>(Results.Empty);
}
return System.Threading.Tasks.ValueTask.FromResult<object?>(handler());
},
builder,
handler.Method);
}
System.Threading.Tasks.Task RequestHandler(Microsoft.AspNetCore.Http.HttpContext httpContext)
{
var result = handler();
return httpContext.Response.WriteAsync(result);
}
async System.Threading.Tasks.Task RequestHandlerFiltered(Microsoft.AspNetCore.Http.HttpContext httpContext)
{
var result = await filteredInvocation(new DefaultEndpointFilterInvocationContext(httpContext));
await ExecuteObjectResult(result, httpContext);
}
return filteredInvocation is null ? RequestHandler : RequestHandlerFiltered;
}),
};
internal static Microsoft.AspNetCore.Builder.RouteHandlerBuilder MapPut(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints,
[System.Diagnostics.CodeAnalysis.StringSyntax("Route")] string pattern,
System.Func<string> handler,
[System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
[System.Runtime.CompilerServices.CallerLineNumber]int lineNumber = 0)
{
return MapCore(endpoints, pattern, handler, GetVerb, filePath, lineNumber);
}
private static Microsoft.AspNetCore.Builder.RouteHandlerBuilder MapCore<T>(
this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes,
string pattern,
System.Delegate handler,
IEnumerable<string> httpMethods,
string filePath,
int lineNumber)
{
var (populate, factory) = GenericThunks<T>.map[(filePath, lineNumber)];
return GetOrAddRouteEndpointDataSource(routes).AddRouteHandler(RoutePatternFactory.Parse(pattern), handler, httpMethods, isFallback: false, populate, factory);
}
private static Microsoft.AspNetCore.Builder.RouteHandlerBuilder MapCore(
this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder routes,
string pattern,
System.Delegate handler,
IEnumerable<string> httpMethods,
string filePath,
int lineNumber)
{
var (populate, factory) = map[(filePath, lineNumber)];
return GetOrAddRouteEndpointDataSource(routes).AddRouteHandler(RoutePatternFactory.Parse(pattern), handler, httpMethods, isFallback: false, populate, factory);
}
private static SourceGeneratedRouteEndpointDataSource GetOrAddRouteEndpointDataSource(IEndpointRouteBuilder endpoints)
{
SourceGeneratedRouteEndpointDataSource? routeEndpointDataSource = null;
foreach (var dataSource in endpoints.DataSources)
{
if (dataSource is SourceGeneratedRouteEndpointDataSource foundDataSource)
{
routeEndpointDataSource = foundDataSource;
break;
}
}
if (routeEndpointDataSource is null)
{
routeEndpointDataSource = new SourceGeneratedRouteEndpointDataSource(endpoints.ServiceProvider);
endpoints.DataSources.Add(routeEndpointDataSource);
}
return routeEndpointDataSource;
}
private static EndpointFilterDelegate BuildFilterDelegate(EndpointFilterDelegate filteredInvocation, EndpointBuilder builder, System.Reflection.MethodInfo mi)
{
var routeHandlerFilters = builder.FilterFactories;
var context0 = new EndpointFilterFactoryContext
{
MethodInfo = mi,
ApplicationServices = builder.ApplicationServices,
};
var initialFilteredInvocation = filteredInvocation;
for (var i = routeHandlerFilters.Count - 1; i >= 0; i--)
{
var filterFactory = routeHandlerFilters[i];
filteredInvocation = filterFactory(context0, filteredInvocation);
}
return filteredInvocation;
}
private static void PopulateMetadata<T>(System.Reflection.MethodInfo method, EndpointBuilder builder) where T : Microsoft.AspNetCore.Http.Metadata.IEndpointMetadataProvider
{
T.PopulateMetadata(method, builder);
}
private static void PopulateMetadata<T>(System.Reflection.ParameterInfo parameter, EndpointBuilder builder) where T : Microsoft.AspNetCore.Http.Metadata.IEndpointParameterMetadataProvider
{
T.PopulateMetadata(parameter, builder);
}
private static Task ExecuteObjectResult(object? obj, HttpContext httpContext)
{
if (obj is IResult r)
{
return r.ExecuteAsync(httpContext);
}
else if (obj is string s)
{
return httpContext.Response.WriteAsync(s);
}
else
{
return httpContext.Response.WriteAsJsonAsync(obj);
}
}
private sealed class SourceGeneratedRouteEndpointDataSource : EndpointDataSource
{
private readonly List<RouteEntry> _routeEntries = new();
private readonly IServiceProvider _applicationServices;
public SourceGeneratedRouteEndpointDataSource(IServiceProvider applicationServices)
{
_applicationServices = applicationServices;
}
public RouteHandlerBuilder AddRouteHandler(
RoutePattern pattern,
Delegate routeHandler,
IEnumerable<string> httpMethods,
bool isFallback,
MetadataPopulator metadataPopulator,
RequestDelegateFactoryFunc requestDelegateFactoryFunc)
{
var conventions = new ThrowOnAddAfterEndpointBuiltConventionCollection();
var finallyConventions = new ThrowOnAddAfterEndpointBuiltConventionCollection();
var routeAttributes = RouteAttributes.RouteHandler;
if (isFallback)
{
routeAttributes |= RouteAttributes.Fallback;
}
_routeEntries.Add(new()
{
RoutePattern = pattern,
RouteHandler = routeHandler,
HttpMethods = httpMethods,
RouteAttributes = routeAttributes,
Conventions = conventions,
FinallyConventions = finallyConventions,
RequestDelegateFactory = requestDelegateFactoryFunc,
MetadataPopulator = metadataPopulator,
});
return new RouteHandlerBuilder(new[] { new ConventionBuilder(conventions, finallyConventions) });
}
public override IReadOnlyList<RouteEndpoint> Endpoints
{
get
{
var endpoints = new RouteEndpoint[_routeEntries.Count];
for (int i = 0; i < _routeEntries.Count; i++)
{
endpoints[i] = (RouteEndpoint)CreateRouteEndpointBuilder(_routeEntries[i]).Build();
}
return endpoints;
}
}
public override IReadOnlyList<RouteEndpoint> GetGroupedEndpoints(RouteGroupContext context)
{
var endpoints = new RouteEndpoint[_routeEntries.Count];
for (int i = 0; i < _routeEntries.Count; i++)
{
endpoints[i] = (RouteEndpoint)CreateRouteEndpointBuilder(_routeEntries[i], context.Prefix, context.Conventions, context.FinallyConventions).Build();
}
return endpoints;
}
public override IChangeToken GetChangeToken() => NullChangeToken.Singleton;
private RouteEndpointBuilder CreateRouteEndpointBuilder(
RouteEntry entry, RoutePattern? groupPrefix = null, IReadOnlyList<Action<EndpointBuilder>>? groupConventions = null, IReadOnlyList<Action<EndpointBuilder>>? groupFinallyConventions = null)
{
var pattern = RoutePatternFactory.Combine(groupPrefix, entry.RoutePattern);
var handler = entry.RouteHandler;
var isRouteHandler = (entry.RouteAttributes & RouteAttributes.RouteHandler) == RouteAttributes.RouteHandler;
var isFallback = (entry.RouteAttributes & RouteAttributes.Fallback) == RouteAttributes.Fallback;
var order = isFallback ? int.MaxValue : 0;
var displayName = pattern.RawText ?? pattern.ToString();
if (entry.HttpMethods is not null)
{
// Prepends the HTTP method to the DisplayName produced with pattern + method name
displayName = $"HTTP: {string.Join("", "", entry.HttpMethods)} {displayName}";
}
if (isFallback)
{
displayName = $"Fallback {displayName}";
}
// If we're not a route handler, we started with a fully realized (although unfiltered) RequestDelegate, so we can just redirect to that
// while running any conventions. We'll put the original back if it remains unfiltered right before building the endpoint.
RequestDelegate? factoryCreatedRequestDelegate = null;
// Let existing conventions capture and call into builder.RequestDelegate as long as they do so after it has been created.
RequestDelegate redirectRequestDelegate = context =>
{
if (factoryCreatedRequestDelegate is null)
{
throw new InvalidOperationException("Resources.RouteEndpointDataSource_RequestDelegateCannotBeCalledBeforeBuild");
}
return factoryCreatedRequestDelegate(context);
};
// Add MethodInfo and HttpMethodMetadata (if any) as first metadata items as they are intrinsic to the route much like
// the pattern or default display name. This gives visibility to conventions like WithOpenApi() to intrinsic route details
// (namely the MethodInfo) even when applied early as group conventions.
RouteEndpointBuilder builder = new(redirectRequestDelegate, pattern, order)
{
DisplayName = displayName,
ApplicationServices = _applicationServices,
};
if (isRouteHandler)
{
builder.Metadata.Add(handler.Method);
}
if (entry.HttpMethods is not null)
{
builder.Metadata.Add(new HttpMethodMetadata(entry.HttpMethods));
}
// Apply group conventions before entry-specific conventions added to the RouteHandlerBuilder.
if (groupConventions is not null)
{
foreach (var groupConvention in groupConventions)
{
groupConvention(builder);
}
}
// Any metadata inferred directly inferred by RDF or indirectly inferred via IEndpoint(Parameter)MetadataProviders are
// considered less specific than method-level attributes and conventions but more specific than group conventions
// so inferred metadata gets added in between these. If group conventions need to override inferred metadata,
// they can do so via IEndpointConventionBuilder.Finally like the do to override any other entry-specific metadata.
if (isRouteHandler)
{
entry.MetadataPopulator(entry.RouteHandler, builder);
}
// Add delegate attributes as metadata before entry-specific conventions but after group conventions.
var attributes = handler.Method.GetCustomAttributes();
if (attributes is not null)
{
foreach (var attribute in attributes)
{
builder.Metadata.Add(attribute);
}
}
entry.Conventions.IsReadOnly = true;
foreach (var entrySpecificConvention in entry.Conventions)
{
entrySpecificConvention(builder);
}
// If no convention has modified builder.RequestDelegate, we can use the RequestDelegate returned by the RequestDelegateFactory directly.
var conventionOverriddenRequestDelegate = ReferenceEquals(builder.RequestDelegate, redirectRequestDelegate) ? null : builder.RequestDelegate;
if (isRouteHandler || builder.FilterFactories.Count > 0)
{
factoryCreatedRequestDelegate = entry.RequestDelegateFactory(entry.RouteHandler, builder);
}
Debug.Assert(factoryCreatedRequestDelegate is not null);
// Use the overridden RequestDelegate if it exists. If the overridden RequestDelegate is merely wrapping the final RequestDelegate,
// it will still work because of the redirectRequestDelegate.
builder.RequestDelegate = conventionOverriddenRequestDelegate ?? factoryCreatedRequestDelegate;
entry.FinallyConventions.IsReadOnly = true;
foreach (var entryFinallyConvention in entry.FinallyConventions)
{
entryFinallyConvention(builder);
}
if (groupFinallyConventions is not null)
{
// Group conventions are ordered by the RouteGroupBuilder before
// being provided here.
foreach (var groupFinallyConvention in groupFinallyConventions)
{
groupFinallyConvention(builder);
}
}
return builder;
}
private readonly struct RouteEntry
{
public MetadataPopulator MetadataPopulator { get; init; }
public RequestDelegateFactoryFunc RequestDelegateFactory { get; init; }
public RoutePattern RoutePattern { get; init; }
public Delegate RouteHandler { get; init; }
public IEnumerable<string> HttpMethods { get; init; }
public RouteAttributes RouteAttributes { get; init; }
public ThrowOnAddAfterEndpointBuiltConventionCollection Conventions { get; init; }
public ThrowOnAddAfterEndpointBuiltConventionCollection FinallyConventions { get; init; }
}
[Flags]
private enum RouteAttributes
{
// The endpoint was defined by a RequestDelegate, RequestDelegateFactory.Create() should be skipped unless there are endpoint filters.
None = 0,
// This was added as Delegate route handler, so RequestDelegateFactory.Create() should always be called.
RouteHandler = 1,
// This was added by MapFallback.
Fallback = 2,
}
// This private class is only exposed to internal code via ICollection<Action<EndpointBuilder>> in RouteEndpointBuilder where only Add is called.
private sealed class ThrowOnAddAfterEndpointBuiltConventionCollection : List<Action<EndpointBuilder>>, ICollection<Action<EndpointBuilder>>
{
// We throw if someone tries to add conventions to the RouteEntry after endpoints have already been resolved meaning the conventions
// will not be observed given RouteEndpointDataSource is not meant to be dynamic and uses NullChangeToken.Singleton.
public bool IsReadOnly { get; set; }
void ICollection<Action<EndpointBuilder>>.Add(Action<EndpointBuilder> convention)
{
if (IsReadOnly)
{
throw new InvalidOperationException("Resources.RouteEndpointDataSource_ConventionsCannotBeModifiedAfterBuild");
}
Add(convention);
}
}
private sealed class ConventionBuilder : IEndpointConventionBuilder
{
private readonly ICollection<Action<EndpointBuilder>> _conventions;
private readonly ICollection<Action<EndpointBuilder>> _finallyConventions;
public ConventionBuilder(ICollection<Action<EndpointBuilder>> conventions, ICollection<Action<EndpointBuilder>> finallyConventions)
{
_conventions = conventions;
_finallyConventions = finallyConventions;
}
/// <summary>
/// Adds the specified convention to the builder. Conventions are used to customize <see cref="EndpointBuilder"/> instances.
/// </summary>
/// <param name="convention">The convention to add to the builder.</param>
public void Add(Action<EndpointBuilder> convention)
{
_conventions.Add(convention);
}
public void Finally(Action<EndpointBuilder> finalConvention)
{
_finallyConventions.Add(finalConvention);
}
}
}
} The entrypoint from user code is the implementation of the strongly-typed
These are emitted by the source generator when analyzing each The method body of these strongly-typed
In Both the functionalities above are supported by the
It takes the delegates defined in #1 and #2 and plugs them into the endpoint construction that happens as part of the RouteEndpointDataSource. If we include these features in framework, we can remove this custom implementation from the emitted source code and rely on that. |
Note: |
Fix for dotnet/roslyn-analyzers#6229 is merged now. You can still include an explicit reference if you want to use latest previews, but otherwise, it should be good to have the transitive dependency. |
A scenario to watch out for emerged in our benchmark tests:
We should de-dupe endpoints that evaluate to the same delegate signature. |
Would be nice if SG could generate some static class containing paths to all endpoints. For example, this
should lead to something like:
|
What's the motivation for having this? Largely as a debugging tool to inspect all the endpoints in an app or are you wanting to use these strings somewhere? |
For integration tests with Refit, or for redirects when developing (no reverse proxy at this point) |
That's an idea we've discussed in some other contexts recently but deserves its own issue. We'd likely want it to discover more than just routes created via minimal APIs (e.g. Razor Pages, MVC, etc.). |
Currently, RequestDelegates for associated route handler-based endpoints are generated at runtime using LINQ expressions. This is a process that requires a fair amount of reflection. We can leverage source generation to generate these endpoints via static analysis.
Some of this work has been prototyped in uController and will need to be formalized as part of the official project.
Generator Basics
EnableRequestDelegateGenerator
flag in Web SDKGeneratedRouteBuilderExtensions
asfile class
Invoking Handlers
EndpointFilterInvocationContext
for a given filtered invocationThe text was updated successfully, but these errors were encountered: