Extend source generator to auto-register endpoints from referenced assemblies#104
Extend source generator to auto-register endpoints from referenced assemblies#104
Conversation
… duplicate-type conflicts; add external assembly scanning via duck-typing Co-authored-by: marcominerva <3522534+marcominerva@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Extends the MinimalHelpers.Routing.Analyzers source generator so MapEndpoints() can auto-register endpoint classes defined in referenced projects/assemblies (enabling modular endpoint libraries without reflection), and changes generated types to internal to avoid duplicate-type conflicts across build graphs.
Changes:
- Added a second incremental pipeline that scans referenced assemblies and discovers endpoints via duck-typed
public static void MapEndpoints(IEndpointRouteBuilder)methods. - Combined current-compilation discovery with external-assembly discovery and updated generator execution accordingly.
- Made generated
IEndpointRouteHandlerBuilderandEndpointRouteBuilderExtensionsinternalto preventCS0433duplicate type conflicts.
Comments suppressed due to low confidence (1)
src/MinimalHelpers.Routing.Analyzers/EndpointRouteHandlerGenerator.cs:149
- The generated XML doc has an invalid
crefdue to an extra trailing space (<see cref="IEndpointRouteHandlerBuilder "/>). This can produce XML doc warnings (or failures if warnings are treated as errors). Remove the trailing space so the cref resolves correctly.
/// Automatically registers all the route endpoints defined in classes that implement the <see cref="IEndpointRouteHandlerBuilder "/> interface.
/// </summary>
| m.DeclaredAccessibility == Accessibility.Public && | ||
| m.ReturnsVoid && | ||
| m.Parameters.Length == 1 && | ||
| m.Parameters[0].Type.Name == "IEndpointRouteBuilder" && | ||
| m.Parameters[0].Type.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore.Routing"); |
There was a problem hiding this comment.
HasMapEndpointsMethod can match methods that will make the generated call uncompilable (e.g., MapEndpoints<T>(IEndpointRouteBuilder)), because it doesn't exclude generic methods or verify the parameter type via symbol equality. Tighten the predicate to require a non-generic method and compare the parameter type against compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Routing.IEndpointRouteBuilder") using SymbolEqualityComparer.Default.
| m.DeclaredAccessibility == Accessibility.Public && | |
| m.ReturnsVoid && | |
| m.Parameters.Length == 1 && | |
| m.Parameters[0].Type.Name == "IEndpointRouteBuilder" && | |
| m.Parameters[0].Type.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore.Routing"); | |
| !m.IsGenericMethod && | |
| m.DeclaredAccessibility == Accessibility.Public && | |
| m.ReturnsVoid && | |
| m.Parameters.Length == 1 && | |
| m.Parameters[0].Type is INamedTypeSymbol parameterType && | |
| parameterType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Microsoft.AspNetCore.Routing.IEndpointRouteBuilder"); |
| var validClasses = input.CurrentCompilation | ||
| .Where(static symbol => symbol is not null) | ||
| .Cast<INamedTypeSymbol>() | ||
| .Concat(input.External) |
There was a problem hiding this comment.
validClasses is built by concatenating current + external results without de-duplication or a deterministic order. If the same external assembly is referenced twice (or the symbol enumeration order changes), this can generate duplicate MapEndpoints calls and/or non-deterministic output. Consider Distinct(SymbolEqualityComparer.Default) and ordering by a stable key (e.g., fully-qualified name) before emitting code.
| .Concat(input.External) | |
| .Concat(input.External) | |
| .Distinct(SymbolEqualityComparer.Default) | |
| .OrderBy(static symbol => symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) |
| var externalEndpointClasses = context.CompilationProvider | ||
| .SelectMany(static (compilation, token) => | ||
| { | ||
| var results = ImmutableArray.CreateBuilder<INamedTypeSymbol>(); | ||
|
|
||
| foreach (var reference in compilation.References) | ||
| { | ||
| token.ThrowIfCancellationRequested(); | ||
|
|
||
| if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assemblySymbol) | ||
| { | ||
| CollectEndpointTypesFromNamespace(assemblySymbol.GlobalNamespace, results, token); | ||
| } | ||
| } | ||
|
|
||
| return results.ToImmutable(); | ||
| }) | ||
| .Collect(); |
There was a problem hiding this comment.
The external-assembly pipeline walks every type in every referenced assembly (compilation.References + full namespace recursion). This can significantly slow builds for large projects. Consider filtering references before scanning (e.g., only scan assemblies that reference the assembly containing Microsoft.AspNetCore.Routing.IEndpointRouteBuilder, which you can obtain from compilation.GetTypeByMetadataName(...)) to avoid traversing unrelated framework/NuGet assemblies.
The source generator only discovered
IEndpointRouteHandlerBuilderimplementations in the current compilation, making cross-project endpoint modularisation impossible without falling back to reflection-basedMapEndpointsFromAssembly.Changes
External assembly scanning
CompilationProvider.SelectManythat walks the full namespace tree of every referenced assembly symbol, collecting endpoint classes via duck-typing: apublic, non-abstract, non-generic class with apublic static void MapEndpoints(IEndpointRouteBuilder)method.internal(see below), so it is invisible across assembly boundaries..Combine()and merged inExecute.internalgenerated types (prevents duplicate-type conflicts)Both generated types were
public, causingCS0433errors when two projects in the same build graph each referenceMinimalHelpers.Routing.Analyzers(each assembly compiles its own copy, the consuming project sees two definitions):IEndpointRouteHandlerBuilderpublic interfaceinternal interfaceEndpointRouteBuilderExtensionspublic static classinternal static classEach assembly now gets its own private copy that never leaks into public metadata.
Usage
An endpoint class in an external library:
The main API project simply calls
app.MapEndpoints()— the generator includesPeopleEndpointsautomatically, with no reflection and full Native AOT compatibility.Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.