diff --git a/Directory.Packages.props b/Directory.Packages.props index c22a3bf..a020c04 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/README.md b/README.md index 30ebd7d..88eacb8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ public static Task Main() { var collection = new ServiceCollection(); collection.AddRequestDispatcher(builder => builder - .Add(typeof(ScalarHandler)) + .SearchHandlerInAssembly(typeof(ScalarHandler).Assembly) .Add(typeof(ScalarBehavior))); await using var provider = collection.BuildServiceProvider(); var dispatcher = provider.GetRequiredService(); @@ -46,17 +46,17 @@ public class ScalarRequest : IScalarRequest public string Value { get; set; } = string.Empty; } -public class ScalarHandler : IScalarRequestHandler +public class ScalarHandler : IScalarRequestHandler { - public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + public Task HandleAsync(ScalarRequest request, CancellationToken cancellationToken) { return Task.FromResult($"{request.Value} World"); } } -public class ScalarBehavior : IScalarRequestBehavior +public class ScalarBehavior : IScalarRequestBehavior { - public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + public async Task HandleAsync(ScalarRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) { Console.WriteLine("Before"); var result = await next(request, cancellationToken); diff --git a/src/request.tests/Geekeey.Request.Tests.csproj b/src/request.tests/Geekeey.Request.Tests.csproj index ec6ec2a..da5693e 100644 --- a/src/request.tests/Geekeey.Request.Tests.csproj +++ b/src/request.tests/Geekeey.Request.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/src/request.tests/SearchHandlerInAssemblyTests.cs b/src/request.tests/SearchHandlerInAssemblyTests.cs new file mode 100644 index 0000000..4ff15fb --- /dev/null +++ b/src/request.tests/SearchHandlerInAssemblyTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Geekeey.Request.Tests; + +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 services = new ServiceCollection(); + services.AddRequestDispatcher(builder => builder + .SearchHandlerInAssembly(assembly.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 handlerInterface = typeof(IScalarRequestHandler<,>).MakeGenericType(requestType, typeof(string)); + var handlers = GetRequestHandlers(options, handlerInterface, provider).ToArray(); + + using var scope = Assert.Multiple(); + await Assert.That(handlers).Count().IsEqualTo(1); + await Assert.That(handlers.Single()!.GetType()).IsEqualTo(handlerType); + } + + [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 services = new ServiceCollection(); + services.AddRequestDispatcher(builder => builder + .SearchHandlerInAssembly(assembly.Assembly, ServiceLifetime.Singleton)); + var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!; + var descriptor = services.SingleOrDefault(service => service.ServiceType == handlerType); + + using var scope = Assert.Multiple(); + await Assert.That(descriptor).IsNotNull(); + await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Singleton); + } + + [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 services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + + await Assert.That(() => builder.SearchHandlerInAssembly(assembly.Assembly)) + .Throws().And.HasMessageContaining("Sample.Container+PingHandler"); + } + + private static IEnumerable GetRequestHandlers(RequestDispatcherOptions options, Type handlerInterface, + IServiceProvider provider) + { + return ((IEnumerable)typeof(RequestDispatcherOptions) + .GetMethod(nameof(RequestDispatcherOptions.GetRequestHandlers))! + .MakeGenericMethod(handlerInterface) + .Invoke(options, [provider])!) + .Cast(); + } +} diff --git a/src/request.tests/_helpers/DynamicTestAssembly.cs b/src/request.tests/_helpers/DynamicTestAssembly.cs new file mode 100644 index 0000000..c443666 --- /dev/null +++ b/src/request.tests/_helpers/DynamicTestAssembly.cs @@ -0,0 +1,67 @@ +// 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/RequestDispatcherBuilderExtensions.cs b/src/request/RequestDispatcherBuilderExtensions.cs index b0013e2..b0a680f 100644 --- a/src/request/RequestDispatcherBuilderExtensions.cs +++ b/src/request/RequestDispatcherBuilderExtensions.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: EUPL-1.2 using System.Reflection; +using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -20,6 +21,12 @@ public static class RequestDispatcherBuilderExtensions /// Searches for request handler types within the specified assembly and adds them to the request dispatcher /// configuration. /// + /// + /// Prefer the generated Add<Name>(builder) extensions for assemblies that directly reference + /// Geekeey.Request. This runtime scanning API remains supported, but nested request handlers are rejected + /// during registration and generated registration should not be mixed with + /// for the same assembly. + /// /// The to configure. /// The assembly to search for request handler types. /// The instance for further configuration. @@ -29,7 +36,7 @@ public static class RequestDispatcherBuilderExtensions var exports = assembly.GetTypes() .Where(type => type is { IsClass: true, IsAbstract: false }) - .Where(IsRequestHandlerType); + .Where(IsRequestHandlerImplementationType); builder.Add(exports); @@ -40,6 +47,12 @@ public static class RequestDispatcherBuilderExtensions /// Searches for request handler types within the specified assembly and adds them to the request dispatcher /// configuration with the given service lifetime. /// + /// + /// Prefer the generated Add<Name>(builder, lifetime) extensions for assemblies that directly reference + /// Geekeey.Request. This runtime scanning API remains supported, but nested request handlers are rejected + /// during registration and generated registration should not be mixed with + /// for the same assembly. + /// /// The to configure. /// The assembly to search for request handler types. /// The lifetime with which the request handlers are registered in the dependency injection container. @@ -50,7 +63,7 @@ public static class RequestDispatcherBuilderExtensions var exports = assembly.GetTypes() .Where(type => type is { IsClass: true, IsAbstract: false }) - .Where(IsRequestHandlerType); + .Where(IsRequestHandlerImplementationType); builder.Add(exports, lifetime); @@ -66,6 +79,7 @@ public static class RequestDispatcherBuilderExtensions public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, Type type) { ArgumentNullException.ThrowIfNull(builder); + ValidateNoNestedRequestHandlers([type]); builder.Services.AddOptions() .Configure(options => options.Inspect([type])); @@ -85,6 +99,7 @@ public static class RequestDispatcherBuilderExtensions public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, Type type, ServiceLifetime lifetime) { ArgumentNullException.ThrowIfNull(builder); + ValidateNoNestedRequestHandlers([type]); builder.Services.AddOptions() .Configure(options => options.Inspect([type])); @@ -104,8 +119,10 @@ public static class RequestDispatcherBuilderExtensions { ArgumentNullException.ThrowIfNull(builder); + var types = ValidateNoNestedRequestHandlers([.. type]); + builder.Services.AddOptions() - .Configure(options => options.Inspect(type)); + .Configure(options => options.Inspect(types)); return builder; } @@ -123,11 +140,35 @@ public static class RequestDispatcherBuilderExtensions { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddOptions() - .Configure(options => options.Inspect(type)); + var types = ValidateNoNestedRequestHandlers([.. type]); - builder.Services.Add(type.Select(export => new ServiceDescriptor(export, export, lifetime))); + builder.Services.AddOptions() + .Configure(options => options.Inspect(types)); + + builder.Services.Add(types.Select(export => new ServiceDescriptor(export, export, lifetime))); return builder; } + + private static IReadOnlyCollection ValidateNoNestedRequestHandlers(IReadOnlyCollection types, [CallerMemberName] string? invoker = null) + { + var nestedHandlers = types + .Where(type => type is { IsClass: true, IsAbstract: false, IsNested: true }) + .Where(IsRequestHandlerImplementationType) + .Select(type => type.FullName ?? type.Name) + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + + if (nestedHandlers.Length > 0) + { + throw new InvalidOperationException($"Nested request handlers are not supported by {invoker}: {string.Join(", ", nestedHandlers)}"); + } + + return types; + } + + private static bool IsRequestHandlerImplementationType(Type type) + { + return type.GetInterfaces().Any(IsRequestHandlerType); + } } diff --git a/src/request/package-readme.md b/src/request/package-readme.md index 492332e..b816f29 100644 --- a/src/request/package-readme.md +++ b/src/request/package-readme.md @@ -25,7 +25,7 @@ public static Task Main() { var collection = new ServiceCollection(); collection.AddRequestDispatcher(builder => builder - .Add(typeof(ScalarHandler)) + .SearchHandlerInAssembly(typeof(ScalarHandler).Assembly) .Add(typeof(ScalarBehavior))); await using var provider = collection.BuildServiceProvider(); var dispatcher = provider.GetRequiredService(); @@ -42,17 +42,17 @@ public class ScalarRequest : IScalarRequest public string Value { get; set; } = string.Empty; } -public class ScalarHandler : IScalarRequestHandler +public class ScalarHandler : IScalarRequestHandler { - public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + public Task HandleAsync(ScalarRequest request, CancellationToken cancellationToken) { return Task.FromResult($"{request.Value} World"); } } -public class ScalarBehavior : IScalarRequestBehavior +public class ScalarBehavior : IScalarRequestBehavior { - public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + public async Task HandleAsync(ScalarRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) { Console.WriteLine("Before"); var result = await next(request, cancellationToken);