diff --git a/README.md b/README.md index 88eacb8..87b38e7 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,29 @@ public class ScalarBehavior : IScalarRequestBehavior } ``` +### 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 `false` +- rename the generated `Add(...)` 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 conatiner or are created on the fly but can receive arguments from the DI +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. diff --git a/request.slnx b/request.slnx index aa2a0cb..72efa9b 100644 --- a/request.slnx +++ b/request.slnx @@ -1,4 +1,5 @@ + diff --git a/src/request.generator/CompiletimeRequestDispatchGenerator.cs b/src/request.generator/CompiletimeRequestDispatchGenerator.cs new file mode 100644 index 0000000..8bad8a7 --- /dev/null +++ b/src/request.generator/CompiletimeRequestDispatchGenerator.cs @@ -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 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(SymbolEqualityComparer.Default); + var topLevelHandlers = new HashSet(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 handlers) + { + var builder = new StringBuilder() + .AppendLine("// ") + .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); + } + } +} diff --git a/src/request.generator/Geekeey.Request.Generator.csproj b/src/request.generator/Geekeey.Request.Generator.csproj new file mode 100644 index 0000000..5a5943d --- /dev/null +++ b/src/request.generator/Geekeey.Request.Generator.csproj @@ -0,0 +1,24 @@ + + + + Library + netstandard2.0 + latest + false + + + + $(NoWarn);RS1036;RS2008 + true + true + + + + + + + + + + + diff --git a/src/request.generator/Geekeey.Request.Generator.props b/src/request.generator/Geekeey.Request.Generator.props new file mode 100644 index 0000000..c5c209d --- /dev/null +++ b/src/request.generator/Geekeey.Request.Generator.props @@ -0,0 +1,11 @@ + + + true + + + + + + + + diff --git a/src/request.tests/CompiletimeRequestDispatchGeneratorTests.cs b/src/request.tests/CompiletimeRequestDispatchGeneratorTests.cs new file mode 100644 index 0000000..13d38e6 --- /dev/null +++ b/src/request.tests/CompiletimeRequestDispatchGeneratorTests.cs @@ -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(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(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(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(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(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(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(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(); + } +} diff --git a/src/request.tests/Geekeey.Request.Tests.csproj b/src/request.tests/Geekeey.Request.Tests.csproj index da5693e..aa43848 100644 --- a/src/request.tests/Geekeey.Request.Tests.csproj +++ b/src/request.tests/Geekeey.Request.Tests.csproj @@ -18,6 +18,12 @@ + + + + + + diff --git a/src/request.tests/PackageContentsTests.cs b/src/request.tests/PackageContentsTests.cs new file mode 100644 index 0000000..e7329df --- /dev/null +++ b/src/request.tests/PackageContentsTests.cs @@ -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"); + } +} diff --git a/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs b/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs index e2add03..6724d47 100644 --- a/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs +++ b/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs @@ -100,9 +100,43 @@ internal sealed class RequestDispatcherBuilderExtensionsTests using (Assert.Multiple()) { await Assert.That(() => builder.Add(typeof(TestHandler))).Throws(); - await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)).Throws(); + await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)) + .Throws(); await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws(); - await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)).Throws(); + await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)) + .Throws(); } } + + [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(); + 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(); + await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!); + } } diff --git a/src/request.tests/SearchHandlerInAssemblyTests.cs b/src/request.tests/SearchHandlerInAssemblyTests.cs index 4ff15fb..2c9c073 100644 --- a/src/request.tests/SearchHandlerInAssemblyTests.cs +++ b/src/request.tests/SearchHandlerInAssemblyTests.cs @@ -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 - { - } - - public sealed class PingHandler : IScalarRequestHandler - { - public Task 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>().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 - { - } - - public sealed class PingHandler : IScalarRequestHandler - { - public Task 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 - { - } - - public sealed class Container - { - public sealed class PingHandler : IScalarRequestHandler - { - public Task 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().And.HasMessageContaining("Sample.Container+PingHandler"); } diff --git a/src/request.tests/_fixtures/roslyn/BuilderNestedHandler.cs b/src/request.tests/_fixtures/roslyn/BuilderNestedHandler.cs new file mode 100644 index 0000000..366e048 --- /dev/null +++ b/src/request.tests/_fixtures/roslyn/BuilderNestedHandler.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +using Geekeey.Request; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class Container +{ + public sealed class PingHandler : IScalarRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); + } +} diff --git a/src/request.tests/_fixtures/roslyn/GeneratorNestedHandlers.cs b/src/request.tests/_fixtures/roslyn/GeneratorNestedHandlers.cs new file mode 100644 index 0000000..ec3ff1e --- /dev/null +++ b/src/request.tests/_fixtures/roslyn/GeneratorNestedHandlers.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using Geekeey.Request; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class Container +{ + public sealed class PingHandler : IScalarRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); + } +} diff --git a/src/request.tests/_fixtures/roslyn/GeneratorNoHandlers.cs b/src/request.tests/_fixtures/roslyn/GeneratorNoHandlers.cs new file mode 100644 index 0000000..39b0ba5 --- /dev/null +++ b/src/request.tests/_fixtures/roslyn/GeneratorNoHandlers.cs @@ -0,0 +1,5 @@ +namespace Sample; + +public sealed class Marker +{ +} diff --git a/src/request.tests/_fixtures/roslyn/GeneratorTopLevelHandlers.cs b/src/request.tests/_fixtures/roslyn/GeneratorTopLevelHandlers.cs new file mode 100644 index 0000000..6f6a33a --- /dev/null +++ b/src/request.tests/_fixtures/roslyn/GeneratorTopLevelHandlers.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using Geekeey.Request; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class PingHandler : IScalarRequestHandler +{ + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); +} + +public sealed class GenericPing : IScalarRequest +{ +} + +public sealed class OpenPingHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(GenericPing request, CancellationToken cancellationToken) => Task.FromResult("pong"); +} + +public sealed class PingBehavior : IScalarRequestBehavior +{ + public Task HandleAsync(Ping request, ScalarHandlerDelegate next, CancellationToken cancellationToken) => next(request, cancellationToken); +} diff --git a/src/request.tests/_fixtures/roslyn/SearchNestedHandler.cs b/src/request.tests/_fixtures/roslyn/SearchNestedHandler.cs new file mode 100644 index 0000000..366e048 --- /dev/null +++ b/src/request.tests/_fixtures/roslyn/SearchNestedHandler.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +using Geekeey.Request; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class Container +{ + public sealed class PingHandler : IScalarRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); + } +} diff --git a/src/request.tests/_fixtures/roslyn/SearchTopLevelHandler.cs b/src/request.tests/_fixtures/roslyn/SearchTopLevelHandler.cs new file mode 100644 index 0000000..8e6cea6 --- /dev/null +++ b/src/request.tests/_fixtures/roslyn/SearchTopLevelHandler.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +using Geekeey.Request; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class PingHandler : IScalarRequestHandler +{ + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); +} diff --git a/src/request.tests/_helpers/DynamicTestAssembly.cs b/src/request.tests/_helpers/DynamicTestAssembly.cs deleted file mode 100644 index c443666..0000000 --- a/src/request.tests/_helpers/DynamicTestAssembly.cs +++ /dev/null @@ -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); -} diff --git a/src/request.tests/_helpers/Roslyn.cs b/src/request.tests/_helpers/Roslyn.cs new file mode 100644 index 0000000..5d0885c --- /dev/null +++ b/src/request.tests/_helpers/Roslyn.cs @@ -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 References { get; } = new HashSet(); + public IDictionary Properties { get; } = new Dictionary(StringComparer.Ordinal); + public ICollection SourceCode { get; } = new HashSet(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? 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(Action? 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(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 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 _values; + + public TestAnalyzerConfigOptions(IDictionary 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; + } + } +} diff --git a/src/request/Geekeey.Request.csproj b/src/request/Geekeey.Request.csproj index b140fb7..253e495 100644 --- a/src/request/Geekeey.Request.csproj +++ b/src/request/Geekeey.Request.csproj @@ -33,4 +33,16 @@ + + + + + + + + + + + + diff --git a/src/request/package-readme.md b/src/request/package-readme.md index b816f29..147d90a 100644 --- a/src/request/package-readme.md +++ b/src/request/package-readme.md @@ -61,3 +61,30 @@ public class ScalarBehavior : IScalarRequestBehavior } } ``` + +### 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 `false` +- rename the generated `Add(...)` 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.