feat: add Roslyn-based compile-time request handler registration

Introduce compile-time registration capabilities with source generation, enabling automatic handler registration.
This commit is contained in:
Louis Seubert 2026-05-23 21:22:30 +02:00
commit f77c1ff29b
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
20 changed files with 918 additions and 133 deletions

View file

@ -0,0 +1,293 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Geekeey.Request.Generator;
[Generator(LanguageNames.CSharp)]
public sealed class CompileTimeRequestDispatchGenerator : IIncrementalGenerator
{
private const string DefaultNamespace = "Geekeey.Request";
private const string ScalarHandlerMetadataName = "Geekeey.Request.IScalarRequestHandler`2";
private const string StreamHandlerMetadataName = "Geekeey.Request.IStreamRequestHandler`2";
internal static readonly DiagnosticDescriptor InvalidExplicitName = new(
id: "GKRQ001",
title: "Invalid compile-time registration name",
messageFormat: "CompileTimeRequestDispatchHandlerName value '{0}' is not a valid C# identifier",
category: "Geekeey.Request",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
internal static readonly DiagnosticDescriptor NestedHandlerIgnored = new(
id: "GKRQ002",
title: "Invalid nested request handler",
messageFormat: "Nested request handler '{0}' is not supported by compile-time registration",
category: "Geekeey.Request",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
internal static readonly DiagnosticDescriptor InvalidDerivedName = new(
id: "GKRQ003",
title: "Invalid derived compile-time registration name",
messageFormat: "AssemblyName '{0}' does not produce a valid compile-time registration name. Set CompileTimeRequestDispatchHandlerName to a valid identifier.",
category: "Geekeey.Request",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var options = context.AnalyzerConfigOptionsProvider
.Select(static (provider, _) => GeneratorOptions.Create(provider.GlobalOptions));
var candidates = context.SyntaxProvider
.CreateSyntaxProvider(
static (node, _) => node is ClassDeclarationSyntax,
static (syntaxContext, cancellationToken) => GetCandidate(syntaxContext, cancellationToken))
.Where(static candidate => candidate is not null)
.Select(static (candidate, _) => candidate!.Value)
.Collect();
context.RegisterSourceOutput(options.Combine(candidates),
static (spc, source) => Execute(spc, source.Left, source.Right));
}
private static HandlerCandidate? GetCandidate(GeneratorSyntaxContext context, CancellationToken cancellationToken)
{
var declaration = (ClassDeclarationSyntax)context.Node;
if (context.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not { TypeKind: TypeKind.Class } symbol)
{
return null;
}
if (symbol.IsAbstract || !symbol.AllInterfaces.Any(IsHandlerType))
{
return null;
}
return new HandlerCandidate(symbol, symbol.ContainingType is not null, declaration.Identifier.GetLocation());
static bool IsHandlerType(INamedTypeSymbol @interface)
{
if (!@interface.IsGenericType)
{
return false;
}
var symbol = @interface.OriginalDefinition;
var name = $"{symbol.ContainingNamespace.ToDisplayString()}.{symbol.MetadataName}";
return name is ScalarHandlerMetadataName or StreamHandlerMetadataName;
}
}
private static void Execute(SourceProductionContext context, GeneratorOptions options, ImmutableArray<HandlerCandidate> candidates)
{
if (!options.Enabled)
{
return;
}
if (!TryGetMethodName(options, out var methodName, out var diagnostic))
{
if (diagnostic is not null)
{
context.ReportDiagnostic(diagnostic);
}
return;
}
var nestedHandlers = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
var topLevelHandlers = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var candidate in candidates)
{
if (candidate.IsNested)
{
if (nestedHandlers.Add(candidate.Symbol))
{
context.ReportDiagnostic(Diagnostic.Create(
NestedHandlerIgnored,
candidate.Location ?? candidate.Symbol.Locations.FirstOrDefault(),
candidate.Symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)));
}
continue;
}
topLevelHandlers.Add(candidate.Symbol);
}
var handlers = topLevelHandlers
.Select(static symbol => symbol.Arity is 0
? symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
: symbol.ConstructUnboundGenericType().ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
.OrderBy(static typeName => typeName, StringComparer.Ordinal)
.ToImmutableArray();
context.AddSource(
hintName: $"Geekeey.Request.CompileTimeRegistration.{methodName}.g.cs",
source: RenderSource(methodName, handlers));
}
private static bool TryGetMethodName(GeneratorOptions options, out string name, out Diagnostic? diagnostic)
{
if (!string.IsNullOrWhiteSpace(options.ExplicitName))
{
name = options.ExplicitName ?? string.Empty;
if (!SyntaxFacts.IsValidIdentifier(name))
{
name = string.Empty;
diagnostic = Diagnostic.Create(InvalidExplicitName, Location.None, options.ExplicitName);
return false;
}
}
else
{
name = SanitizeAssemblyName(options.AssemblyName);
if (!SyntaxFacts.IsValidIdentifier(name))
{
name = string.Empty;
diagnostic = Diagnostic.Create(InvalidDerivedName, Location.None, options.AssemblyName);
return false;
}
}
diagnostic = null;
return true;
static string SanitizeAssemblyName(string assemblyName)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
return string.Empty;
}
var builder = new StringBuilder(assemblyName.Length + 1);
foreach (var character in assemblyName)
{
if (builder.Length is 0)
{
if (SyntaxFacts.IsIdentifierStartCharacter(character))
{
builder.Append(character);
}
else if (char.IsDigit(character))
{
builder.Append('_');
builder.Append(character);
}
continue;
}
if (SyntaxFacts.IsIdentifierPartCharacter(character))
{
builder.Append(character);
}
}
return builder.ToString();
}
}
private static string RenderSource(string methodName, ImmutableArray<string> handlers)
{
var builder = new StringBuilder()
.AppendLine("// <auto-generated/>")
.AppendLine("#nullable enable")
.AppendLine()
.AppendLine($"namespace {DefaultNamespace};")
.AppendLine()
.AppendLine($"public static class CompileTimeRequestDispatcherBuilderExtensions_{methodName}")
.AppendLine("{")
.AppendLine($"\tpublic static global::Geekeey.Request.IRequestDispatcherBuilder Add{methodName}(this global::Geekeey.Request.IRequestDispatcherBuilder builder)")
.AppendLine("\t{")
.AppendLine("\t\tglobal::System.ArgumentNullException.ThrowIfNull(builder);");
foreach (var handlerTypeName in handlers)
{
builder.AppendLine($"\t\tbuilder.Add(typeof({handlerTypeName}));");
}
builder
.AppendLine()
.AppendLine("\t\treturn builder;")
.AppendLine("\t}")
.AppendLine()
.AppendLine($"\tpublic static global::Geekeey.Request.IRequestDispatcherBuilder Add{methodName}(this global::Geekeey.Request.IRequestDispatcherBuilder builder, global::Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime)")
.AppendLine("\t{")
.AppendLine("\t\tglobal::System.ArgumentNullException.ThrowIfNull(builder);");
foreach (var handlerTypeName in handlers)
{
builder.AppendLine($"\t\tbuilder.Add(typeof({handlerTypeName}), lifetime);");
}
builder
.AppendLine()
.AppendLine("\t\treturn builder;")
.AppendLine("\t}")
.AppendLine("}");
return builder.ToString();
}
private readonly record struct HandlerCandidate
{
public HandlerCandidate(INamedTypeSymbol symbol, bool isNested, Location? location)
{
Symbol = symbol;
IsNested = isNested;
Location = location;
}
public INamedTypeSymbol Symbol { get; }
public bool IsNested { get; }
public Location? Location { get; }
}
private sealed record GeneratorOptions
{
private const string AssemblyNameProperty = "build_property.AssemblyName";
private const string EnabledProperty = "build_property.CompileTimeRequestDispatchHandlerRegistration";
private const string NameProperty = "build_property.CompileTimeRequestDispatchHandlerName";
public GeneratorOptions(bool enabled, string assemblyName, string? explicitName)
{
Enabled = enabled;
AssemblyName = assemblyName;
ExplicitName = explicitName;
}
public bool Enabled { get; }
public string AssemblyName { get; }
public string? ExplicitName { get; }
public static GeneratorOptions Create(AnalyzerConfigOptions options)
{
var enabled = !options.TryGetValue(EnabledProperty, out var enabledValue) ||
!bool.TryParse(enabledValue, out var parsedValue) ||
parsedValue;
_ = options.TryGetValue(AssemblyNameProperty, out var assemblyName);
_ = options.TryGetValue(NameProperty, out var explicitName);
return new GeneratorOptions(enabled, assemblyName ?? string.Empty, explicitName);
}
}
}

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup Label="Roslyn Analyzer">
<NoWarn>$(NoWarn);RS1036;RS2008</NoWarn>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="Geekeey.Request.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<CompiletimeRequestDispatchHandlerRegistration Condition="'$(CompiletimeRequestDispatchHandlerRegistration)' == ''">true</CompiletimeRequestDispatchHandlerRegistration>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="AssemblyName" />
<CompilerVisibleProperty Include="CompiletimeRequestDispatchHandlerRegistration" />
<CompilerVisibleProperty Include="CompiletimeRequestDispatchHandlerName" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,147 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Request.Generator;
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Tests;
internal sealed class CompileTimeRequestDispatchGeneratorTests
{
[Test]
public async Task I_can_generate_extensions_for_top_level_request_handlers()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).IsEmpty();
await Assert.That(content).Contains("AddExampleProject");
await Assert.That(content).Contains("typeof(global::Sample.PingHandler)");
await Assert.That(content).Contains("typeof(global::Sample.OpenPingHandler<>)");
await Assert.That(content).DoesNotContain("PingBehavior");
}
[Test]
public async Task I_can_override_the_generated_extension_name()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
options.Properties["CompileTimeRequestDispatchHandlerName"] = "CustomRegistration";
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
await Assert.That(content).Contains("AddCustomRegistration");
}
[Test]
public async Task I_get_an_error_for_an_invalid_explicit_name()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
options.Properties["CompileTimeRequestDispatchHandlerName"] = "not.valid";
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.InvalidExplicitName]);
await Assert.That(result.GeneratedTrees).IsEmpty();
await Assert.That(content).IsNullOrEmpty();
}
[Test]
public async Task I_get_an_error_when_the_assembly_name_cannot_be_sanitized_to_a_valid_identifier()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "...";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.InvalidDerivedName]);
await Assert.That(result.GeneratedTrees).IsEmpty();
await Assert.That(content).IsNullOrEmpty();
}
[Test]
public async Task I_get_an_error_for_nested_handlers_and_they_are_ignored()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorNestedHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.NestedHandlerIgnored]);
await Assert.That(content).DoesNotContain("Container.PingHandler");
await Assert.That(content).DoesNotContain("builder.Add(typeof(");
}
[Test]
public async Task I_still_generate_a_no_op_extension_when_no_handlers_exist()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorNoHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(content).Contains("AddExampleProject");
await Assert.That(content).DoesNotContain("builder.Add(typeof(");
}
[Test]
public async Task I_can_disable_CompileTime_registration_generation()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
options.Properties["CompileTimeRequestDispatchHandlerRegistration"] = "false";
});
await Assert.That(result.GeneratedTrees).IsEmpty();
}
}

View file

@ -18,6 +18,12 @@
</ItemGroup>
<ItemGroup>
<Compile Remove="_fixtures/roslyn/**/*.cs" />
<EmbeddedResource Include="_fixtures/roslyn/**/*.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\request.generator\Geekeey.Request.Generator.csproj" />
<ProjectReference Include="..\request\Geekeey.Request.csproj" />
</ItemGroup>

View file

@ -0,0 +1,51 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics;
using System.IO.Compression;
namespace Geekeey.Request.Tests;
internal sealed class PackageContentsTests
{
private static string RepoRoot => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
private static string ArtifactsDir => Path.Combine(RepoRoot, "artifacts");
private static string ProjectPath => Path.Combine(RepoRoot, "src", "request", "Geekeey.Request.csproj");
private readonly string _outputDir;
public PackageContentsTests()
{
_outputDir = Path.Combine(ArtifactsDir, Path.GetRandomFileName());
}
[Before(Test)]
public async Task SetUpAsync()
{
Directory.CreateDirectory(_outputDir);
}
[After(Test)]
public async Task CleanUpAsync()
{
Directory.Delete(_outputDir, true);
}
[Test]
public async Task I_can_verify_the_package_contains_the_expected_contents()
{
using var process = Process.Start("dotnet", $"pack \"{ProjectPath}\" -o \"{_outputDir}\" -c Release");
await process.WaitForExitAsync();
var pkgs = Directory.GetFiles(_outputDir, "*.nupkg");
await Assert.That(pkgs.Length).IsGreaterThanOrEqualTo(1);
await using var archive = await ZipFile.OpenReadAsync(pkgs[0]);
var entries = archive.Entries.Select(entry => entry.FullName.Replace('\\', '/')).ToHashSet();
await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.dll");
await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.xml");
await Assert.That(entries).Contains("build/Geekeey.Request.props");
await Assert.That(entries).Contains("analyzers/dotnet/cs/Geekeey.Request.Generator.dll");
}
}

View file

@ -100,9 +100,43 @@ internal sealed class RequestDispatcherBuilderExtensionsTests
using (Assert.Multiple())
{
await Assert.That(() => builder.Add(typeof(TestHandler))).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient))
.Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient))
.Throws<ArgumentNullException>();
}
}
[Test]
public async Task I_get_an_exception_when_adding_a_nested_request_handler()
{
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var assembly = Roslyn.Compile(options =>
{
options.AddFromEmbeddedSource("BuilderNestedHandler.cs");
});
var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!;
var exception = await Assert.That(() => builder.Add(nestedHandlerType)).Throws<InvalidOperationException>();
await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!);
}
[Test]
public async Task I_get_an_exception_when_adding_nested_request_handlers_with_a_lifetime()
{
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var assembly = Roslyn.Compile(options =>
{
options.AddFromEmbeddedSource("BuilderNestedHandler.cs");
});
var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!;
var exception = await Assert
.That(() => builder.Add([typeof(TestHandler), nestedHandlerType], ServiceLifetime.Transient))
.Throws<InvalidOperationException>();
await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!);
}
}

View file

@ -13,32 +13,15 @@ internal sealed class SearchHandlerInAssemblyTests
[Test]
public async Task I_can_search_handlers_in_an_assembly()
{
const string source =
"""
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
""";
using var assembly = DynamicTestAssembly.Compile(source);
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs"));
var services = new ServiceCollection();
services.AddRequestDispatcher(builder => builder
.SearchHandlerInAssembly(assembly.Assembly));
.SearchHandlerInAssembly(assembly));
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var requestType = assembly.Assembly.GetType("Sample.Ping", throwOnError: true)!;
var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!;
var requestType = assembly.GetType("Sample.Ping", throwOnError: true)!;
var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!;
var handlerInterface = typeof(IScalarRequestHandler<,>).MakeGenericType(requestType, typeof(string));
var handlers = GetRequestHandlers(options, handlerInterface, provider).ToArray();
@ -50,29 +33,12 @@ internal sealed class SearchHandlerInAssemblyTests
[Test]
public async Task I_can_search_handlers_in_an_assembly_with_a_lifetime()
{
const string source =
"""
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
""";
using var assembly = DynamicTestAssembly.Compile(source);
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs"));
var services = new ServiceCollection();
services.AddRequestDispatcher(builder => builder
.SearchHandlerInAssembly(assembly.Assembly, ServiceLifetime.Singleton));
var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!;
.SearchHandlerInAssembly(assembly, ServiceLifetime.Singleton));
var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!;
var descriptor = services.SingleOrDefault(service => service.ServiceType == handlerType);
using var scope = Assert.Multiple();
@ -83,32 +49,12 @@ internal sealed class SearchHandlerInAssemblyTests
[Test]
public async Task I_get_an_exception_when_nested_handlers_are_present()
{
const string source =
"""
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}
""";
using var assembly = DynamicTestAssembly.Compile(source);
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchNestedHandler.cs"));
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
await Assert.That(() => builder.SearchHandlerInAssembly(assembly.Assembly))
await Assert.That(() => builder.SearchHandlerInAssembly(assembly))
.Throws<InvalidOperationException>().And.HasMessageContaining("Sample.Container+PingHandler");
}

View file

@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}

View file

@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}

View file

@ -0,0 +1,5 @@
namespace Sample;
public sealed class Marker
{
}

View file

@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
public sealed class GenericPing<T> : IScalarRequest<string>
{
}
public sealed class OpenPingHandler<T> : IScalarRequestHandler<GenericPing<T>, string>
{
public Task<string> HandleAsync(GenericPing<T> request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
public sealed class PingBehavior : IScalarRequestBehavior<Ping, string>
{
public Task<string> HandleAsync(Ping request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken) => next(request, cancellationToken);
}

View file

@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}

View file

@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}

View file

@ -1,67 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Geekeey.Request.Tests;
internal sealed class DynamicTestAssembly : IDisposable
{
private readonly WeakReference _context;
private DynamicTestAssembly(Assembly assembly, CollectibleAssemblyLoadContext context)
{
Assembly = assembly;
_context = new WeakReference(context);
}
public Assembly Assembly { get; }
public static DynamicTestAssembly Compile(string source)
{
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Concat([typeof(IRequestDispatcherBuilder).Assembly.Location])
.Distinct(StringComparer.Ordinal)
.Select(static path => MetadataReference.CreateFromFile(path));
var syntax = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default
.WithLanguageVersion(LanguageVersion.Preview));
var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
var compilation = CSharpCompilation.Create($"Dynamic_{Guid.NewGuid():N}", [syntax], references, options);
using var stream = new MemoryStream();
if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics })
{
throw new InvalidOperationException(string.Join(Environment.NewLine,
diagnostics.Select(static diagnostic => diagnostic.ToString())));
}
stream.Seek(0, SeekOrigin.Begin);
var context = new CollectibleAssemblyLoadContext();
return new DynamicTestAssembly(context.LoadFromStream(stream), context);
}
public void Dispose()
{
if (_context.Target is AssemblyLoadContext context)
{
context.Unload();
}
for (var iteration = 0; iteration < 10 && _context.IsAlive; iteration++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
}
private sealed class CollectibleAssemblyLoadContext()
: AssemblyLoadContext(nameof(DynamicTestAssembly), isCollectible: true);
}

View file

@ -0,0 +1,177 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Assembly = System.Reflection.Assembly;
namespace Geekeey.Request.Tests;
internal static class Roslyn
{
private static CSharpCompilation CreateCompilation(CompilerOptions options)
{
if (options.SourceCode.Count is 0)
{
throw new InvalidOperationException("At least one source file must be configured.");
}
var syntax = options.SourceCode
.Select(static sourceCode => CSharpSyntaxTree.ParseText(sourceCode, CSharpParseOptions.Default
.WithLanguageVersion(LanguageVersion.Preview)))
.ToArray();
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Concat(options.References.Select(static assembly => assembly.Location))
.Distinct(StringComparer.Ordinal)
.Select(static path => MetadataReference.CreateFromFile(path));
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
return CSharpCompilation.Create(options.AssemblyName, syntax, references, compilationOptions);
}
public sealed class CompilerOptions
{
public string AssemblyName { get; set; } = Guid.NewGuid().ToString();
public ICollection<Assembly> References { get; } = new HashSet<Assembly>();
public IDictionary<string, string?> Properties { get; } = new Dictionary<string, string?>(StringComparer.Ordinal);
public ICollection<string> SourceCode { get; } = new HashSet<string>(StringComparer.Ordinal);
public void AddFromEmbeddedSource(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var relativePath = $"_fixtures/roslyn/{fileName}";
var suffix = relativePath.Replace(Path.DirectorySeparatorChar, '.')
.Replace(Path.AltDirectorySeparatorChar, '.');
var resourceName = assembly.GetManifestResourceNames()
.SingleOrDefault(name => name.EndsWith(suffix, StringComparison.Ordinal));
if (resourceName is null)
{
throw new InvalidOperationException($"Embedded fixture '{relativePath}' was not found.");
}
if (assembly.GetManifestResourceStream(resourceName) is not { } stream)
{
throw new InvalidOperationException($"Embedded fixture '{resourceName}' could not be opened.");
}
using var reader = new StreamReader(stream);
SourceCode.Add(reader.ReadToEnd());
}
}
public static Assembly Compile(Action<CompilerOptions>? configure = null)
{
var options = new CompilerOptions();
configure?.Invoke(options);
var compilation = CreateCompilation(options);
using var stream = new MemoryStream();
if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics })
{
throw new InvalidOperationException(string.Join(Environment.NewLine,
diagnostics.Select(static diagnostic => diagnostic.ToString())));
}
stream.Seek(0, SeekOrigin.Begin);
return Assembly.Load(stream.ToArray());
}
public static GeneratorDriverRunResult SourceGenerator<TGenerator>(Action<CompilerOptions>? configure = null)
where TGenerator : IIncrementalGenerator, new()
{
var options = new CompilerOptions();
configure?.Invoke(options);
var compilation = CreateCompilation(options);
GeneratorDriver driver = CSharpGeneratorDriver.Create([new TGenerator().AsSourceGenerator()],
parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options,
optionsProvider: new TestAnalyzerConfigOptionsProvider(
new Dictionary<string, string?>(options.Properties, StringComparer.Ordinal)
{
["build_property.AssemblyName"] = options.AssemblyName,
}));
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _);
return driver.GetRunResult();
}
extension(GeneratorDriverRunResult result)
{
public string? File([StringSyntax(StringSyntaxAttribute.Regex)] string name)
{
foreach (var value in result.Results)
{
foreach (var source in value.GeneratedSources)
{
if (Regex.IsMatch(source.HintName, name))
{
return source.SourceText.ToString();
}
}
}
return null;
}
}
private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
{
public TestAnalyzerConfigOptionsProvider(IDictionary<string, string?> values)
{
GlobalOptions = new TestAnalyzerConfigOptions(values);
}
public override AnalyzerConfigOptions GlobalOptions { get; }
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)
{
return GlobalOptions;
}
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
{
return GlobalOptions;
}
}
private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions
{
private readonly ImmutableDictionary<string, string?> _values;
public TestAnalyzerConfigOptions(IDictionary<string, string?> values)
{
_values = values.ToImmutableDictionary(StringComparer.Ordinal);
}
public override bool TryGetValue(string key, out string value)
{
if (_values.TryGetValue(key, out var existingValue) && existingValue is not null)
{
value = existingValue;
return true;
}
if (key.StartsWith("build_property.", StringComparison.Ordinal) &&
_values.TryGetValue(key["build_property.".Length..], out existingValue) &&
existingValue is not null)
{
value = existingValue;
return true;
}
value = string.Empty;
return false;
}
}
}

View file

@ -33,4 +33,16 @@
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<!-- Reference the source generator just to ensure it's built but don't reference its output -->
<ProjectReference Include="..\request.generator\Geekeey.Request.Generator.csproj" OutputItemType="SourceGeneratorAssemblyPath" ReferenceOutputAssembly="false" Private="true" />
</ItemGroup>
<Target Name="IncludeSourceGeneratorInPackage" AfterTargets="ResolveProjectReferences">
<ItemGroup>
<None Include="@(SourceGeneratorAssemblyPath)" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="..\request.generator\Geekeey.Request.Generator.props" Pack="true" PackagePath="build/$(PackageId).props" Visible="false" />
</ItemGroup>
</Target>
</Project>

View file

@ -61,3 +61,30 @@ public class ScalarBehavior : IScalarRequestBehavior<ScalarRequest, string>
}
}
```
### Compile-time registration
Projects that directly reference `Geekeey.Request` also get generated registration methods in the
`Geekeey.Request` namespace:
```csharp
collection.AddRequestDispatcher(builder => builder
.AddExampleProject()
.Add(typeof(ScalarBehavior)));
collection.AddRequestDispatcher(builder => builder
.AddExampleProject(ServiceLifetime.Scoped)
.Add(typeof(ScalarBehavior)));
```
- generation is enabled by default
- disable it with `<CompileTimeRequestDispatchHandlerRegistration>false</CompileTimeRequestDispatchHandlerRegistration>`
- rename the generated `Add<Name>(...)` methods with `CompileTimeRequestDispatchHandlerName`
- only request handlers are generated; behaviors still need normal registration
- nested request handlers are rejected during `Add(...)` registration, including `SearchHandlerInAssembly(...)` and by the source generator
- use one registration style per assembly: generated methods or `SearchHandlerInAssembly(...)`
## Behaviour of the Handlers
Handlers are resolved from either the DI container or are created on the fly but can receive arguments from the DI
container when being constructed. The same also applied for the request pipeline behaviours.