From a5a8505506da6c91965e438f27b0d65992f8da8f Mon Sep 17 00:00:00 2001 From: Casey Date: Sat, 21 Feb 2026 16:21:55 -0700 Subject: [PATCH] feat(Service Registration): Rename pipeline logger and expand docs and test coverage #breaking Refined the service registration pipeline for clarity, consistency, and reliability. Pipeline: - Renamed StartupLogger to Logger in ServiceRegistrationPipeline for clearer abstraction and naming consistency while preserving startup-safe logging semantics (no DI dependency required) Validation: - Refactored expression validation logic to reduce duplication and improve internal reuse (no behavioral changes) Documentation: - Expanded and clarified XML documentation for expression validation invariants, canonical expression normalization, and execution semantics including ordering guarantees - Added README guidance for configuring the pipeline logger, environment-aware logging, and controlled opt-out scenarios Testing: - Increased unit test coverage for invocation expressions, instance methods, invalid extension targets, expression lifting behavior, and expression string formatting (including method call and type binary arguments) - Strengthened log message assertions to verify formatted output explicitly BREAKING CHANGE: - `StartupLogger` property renamed to `Logger` in `ServiceRegistrationPipeline` --- README.md | 156 +++++++++++--- .../ExpressionExtensions.cs | 145 +++++++++---- ...ServiceRegistrationExpressionCollection.cs | 128 ++++++------ .../ServiceRegistrationPipeline.cs | 27 +-- ...iceComposition.NET.IntegrationTests.csproj | 4 + .../TestServiceRegistrationPipeline.cs | 2 +- .../ExpressionExtensionTests.cs | 78 ++++++- .../ServiceComposition.NET.UnitTests.csproj | 4 + ...ceRegistrationExpressionCollectionTests.cs | 196 ++++++++++++++++++ .../ServiceRegistrationPipelineTests.cs | 85 +++++++- .../TestServiceRegistrationPipeline.cs | 2 +- ...stServiceRegistrationPipelineWithLogger.cs | 2 +- 12 files changed, 674 insertions(+), 155 deletions(-) create mode 100644 tst/ServiceComposition.NET.UnitTests/ServiceRegistrationExpressionCollectionTests.cs diff --git a/README.md b/README.md index cab1010..9081c49 100644 --- a/README.md +++ b/README.md @@ -154,11 +154,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -public sealed class CoreServiceRegistrationPipeline : ServiceRegistrationPipeline +public sealed class AppServiceRegistrationPipeline : ServiceRegistrationPipeline { - protected override ILogger StartupLogger => NullLogger.Instance; + protected override ILogger Logger => NullLogger.Instance; - public CoreServiceRegistrationPipeline() + public AppServiceRegistrationPipeline() { AddRegistration((services, config) => services.RegisterConfiguredOptions(config)); @@ -183,7 +183,7 @@ To define one, inherit from `ServiceCompositionRoot` and add registra using Microsoft.Extensions.DependencyInjection; public sealed class WebAppServiceComposition - : ServiceCompositionRoot + : ServiceCompositionRoot { public WebAppServiceComposition() { @@ -196,7 +196,7 @@ public sealed class WebAppServiceComposition } ``` -The generic pipeline type (`CoreServiceRegistrationPipeline`) defines the shared registrations that always run first. Any registrations added here are applied afterward. +The generic pipeline type (`AppServiceRegistrationPipeline`) defines the shared registrations that always run first. Any registrations added here are applied afterward. --- @@ -262,7 +262,7 @@ var configuration = new ConfigurationBuilder() }) .Build(); -var pipeline = new CoreServiceRegistrationPipeline(); +var pipeline = new AppServiceRegistrationPipeline(); pipeline.Execute(services, configuration); var provider = services.BuildServiceProvider(); @@ -366,11 +366,11 @@ The result is a clean, explicit service composition flow where: This keeps startup logic modular, testable, and easy to reason about as applications grow. -### Startup Logging +### Pipeline Logging -ServiceComposition.NET includes first-class support for **startup-time logging** through the `StartupLogger` property on a service registration pipeline. +ServiceComposition.NET includes first-class support for **service registration logging** through the `Logger` property on a service registration pipeline. -Service registrations often run **before the application’s logging infrastructure is fully configured**. The `StartupLogger` exists specifically to cover this gap, giving you visibility into what happens during service composition—when failures are otherwise difficult to diagnose. +Service registrations often run **before the application’s logging infrastructure is fully configured**. The `Logger` exists specifically to cover this gap, giving you visibility into what happens during service composition—when failures are otherwise difficult to diagnose. As each registration expression is executed, the pipeline emits trace-level log entries that indicate: - When a registration starts @@ -379,7 +379,7 @@ As each registration expression is executed, the pipeline emits trace-level log This makes it easy to see the exact order in which services are registered and to pinpoint the registration that caused a startup failure. -You can back `StartupLogger` with any logging implementation you prefer, including Serilog, NLog, or the built-in logging abstractions. Logs can be written to the console, files, or external systems, depending on how early diagnostics need to be captured. +You can back `Logger` with any logging implementation you prefer, including Serilog, NLog, or the built-in logging abstractions. Logs can be written to the console, files, or external systems, depending on how early diagnostics need to be captured. Startup logging is especially useful when: - Diagnosing failures that occur before the host fully starts @@ -391,26 +391,26 @@ By making startup behavior observable, the library helps reduce guesswork and sh #### Example Log Output ```log -[2024:10:13 07:16:48.999 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddOptions(this IServiceCollection)"' was started... -[2024:10:13 07:16:49.043 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddOptions(this IServiceCollection)"' completed successfully! -[2024:10:13 07:16:49.045 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' was started... -[2024:10:13 07:16:49.046 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' completed successfully! -[2024:10:13 07:16:49.048 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' was started... -[2024:10:13 07:16:49.049 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' completed successfully! -[2024:10:13 07:16:49.051 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddOptions(this IServiceCollection, IConfiguration)"' was started... -[2024:10:13 07:16:49.061 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddOptions(this IServiceCollection, IConfiguration)"' completed successfully! -[2024:10:13 07:16:49.064 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddSqlServer(this IServiceCollection)"' was started... -[2024:10:13 07:16:49.067 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddSqlServer(this IServiceCollection)"' completed successfully! -[2024:10:13 07:16:49.170 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddAuthorization(this IServiceCollection, IConfiguration)"' was started... -[2024:10:13 07:16:49.181 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddAuthorization(this IServiceCollection, IConfiguration)"' completed successfully! -[2024:10:13 07:16:49.184 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddRazorPagesWithAuthorization(this IServiceCollection, IConfiguration)"' was started... -[2024:10:13 07:16:49.262 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddRazorPagesWithAuthorization(this IServiceCollection, IConfiguration)"' completed successfully! -[2024:10:13 07:16:49.264 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddCascadingAuthenticationState(this IServiceCollection)"' was started... -[2024:10:13 07:16:49.265 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddCascadingAuthenticationState(this IServiceCollection)"' completed successfully! -[2024:10:13 07:16:49.267 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddServerSideBlazor(this IServiceCollection, Action`1)"' was started... -[2024:10:13 07:16:49.293 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddServerSideBlazor(this IServiceCollection, Action`1)"' completed successfully! -[2024:10:13 07:16:49.294 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddMudBlazorServices(this IServiceCollection, IConfiguration)"' was started... -[2024:10:13 07:16:49.303 PM] [Verbose] [CoreServiceRegistrationPipeline] '"AddMudBlazorServices(this IServiceCollection, IConfiguration)"' completed successfully! +[2024:10:13 07:16:48.999 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddOptions(this IServiceCollection)"' was started... +[2024:10:13 07:16:49.043 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddOptions(this IServiceCollection)"' completed successfully! +[2024:10:13 07:16:49.045 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' was started... +[2024:10:13 07:16:49.046 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' completed successfully! +[2024:10:13 07:16:49.048 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' was started... +[2024:10:13 07:16:49.049 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddTransient(this IServiceCollection, Type)"' completed successfully! +[2024:10:13 07:16:49.051 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddOptions(this IServiceCollection, IConfiguration)"' was started... +[2024:10:13 07:16:49.061 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddOptions(this IServiceCollection, IConfiguration)"' completed successfully! +[2024:10:13 07:16:49.064 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddSqlServer(this IServiceCollection)"' was started... +[2024:10:13 07:16:49.067 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddSqlServer(this IServiceCollection)"' completed successfully! +[2024:10:13 07:16:49.170 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddAuthorization(this IServiceCollection, IConfiguration)"' was started... +[2024:10:13 07:16:49.181 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddAuthorization(this IServiceCollection, IConfiguration)"' completed successfully! +[2024:10:13 07:16:49.184 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddRazorPagesWithAuthorization(this IServiceCollection, IConfiguration)"' was started... +[2024:10:13 07:16:49.262 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddRazorPagesWithAuthorization(this IServiceCollection, IConfiguration)"' completed successfully! +[2024:10:13 07:16:49.264 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddCascadingAuthenticationState(this IServiceCollection)"' was started... +[2024:10:13 07:16:49.265 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddCascadingAuthenticationState(this IServiceCollection)"' completed successfully! +[2024:10:13 07:16:49.267 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddServerSideBlazor(this IServiceCollection, Action`1)"' was started... +[2024:10:13 07:16:49.293 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddServerSideBlazor(this IServiceCollection, Action`1)"' completed successfully! +[2024:10:13 07:16:49.294 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddMudBlazorServices(this IServiceCollection, IConfiguration)"' was started... +[2024:10:13 07:16:49.303 PM] [Verbose] [AppServiceRegistrationPipeline] '"AddMudBlazorServices(this IServiceCollection, IConfiguration)"' completed successfully! info: Microsoft.Hosting.Lifetime[14] Now listening on: https://localhost:7206 info: Microsoft.Hosting.Lifetime[14] @@ -421,6 +421,102 @@ info: Microsoft.Hosting.Lifetime[0] Hosting environment: Development ``` +### Configuring the Pipeline Logger + +A `ServiceRegistrationPipeline` requires an `ILogger` implementation for startup-time logging. + +Because service composition executes before the application’s DI container is built, the logger must not depend on services resolved from the container. It should be constructed independently. + +#### Example: Using Serilog + +```csharp +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Extensions.Logging; + +public sealed class AppServiceRegistrationPipeline : ServiceRegistrationPipeline +{ + private static readonly ILogger _logger = + new SerilogLoggerFactory( + new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Console() + .CreateLogger()) + .CreateLogger(nameof(AppServiceRegistrationPipeline)); + + protected override ILogger Logger => _logger; + + public AppServiceRegistrationPipeline() + { + AddRegistration((services, config) => + services.AddSingleton()); + } +} +``` + +#### Example: Environment-aware logging + +You can vary logging behavior based on environment or configuration, since the pipeline logger is created independently of the DI container. + +```csharp +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Events; +using Serilog.Extensions.Logging; + +public sealed class AppServiceRegistrationPipeline : ServiceRegistrationPipeline +{ + private static readonly ILogger _logger = CreateLogger(); + + protected override ILogger Logger => _logger; + + private static ILogger CreateLogger() + { + var environment = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + var level = environment == Environments.Development + ? LogEventLevel.Verbose + : LogEventLevel.Information; + + var serilog = new LoggerConfiguration() + .MinimumLevel.Is(level) + .WriteTo.Console() + .CreateLogger(); + + return new SerilogLoggerFactory(serilog) + .CreateLogger(nameof(AppServiceRegistrationPipeline)); + } +} +``` + +This allows detailed startup traces in development while keeping production logs focused and concise. + +### Opting Out of Logging + +If service registration logging is not required, you can return `NullLogger.Instance`: + +```csharp +protected override ILogger Logger => NullLogger.Instance; +``` + +Startup logging is a core feature of **ServiceComposition.NET**. It provides visibility into registration order and early failures—particularly useful when diagnosing issues that occur before the host is fully built. + +However, not every host benefits from verbose startup output. For example, CLI applications or .NET global tools often prioritize clean console output for end users. In those cases, you may prefer to reduce the log level or disable startup logging entirely to avoid cluttering the user experience. + +Because the pipeline logger is created independently of the DI container, you can control its behavior using environment variables, configuration values, or command-line arguments: + +```csharp +protected override ILogger Logger => + Environment.GetEnvironmentVariable("SERVICE_COMPOSITION_VERBOSE") == "true" + ? _verboseLogger + : NullLogger.Instance; +``` + +This enables detailed startup diagnostics when needed while keeping production hosts and console tools quiet by default. + +When reducing or disabling logging, ensure that alternative diagnostics are available where appropriate—for example, enabling verbose mode behind a debug flag. + --- ## ✔ Writing Valid Service Registration Expressions diff --git a/src/ServiceComposition.NET/ExpressionExtensions.cs b/src/ServiceComposition.NET/ExpressionExtensions.cs index d9c3cf6..d05b7d0 100644 --- a/src/ServiceComposition.NET/ExpressionExtensions.cs +++ b/src/ServiceComposition.NET/ExpressionExtensions.cs @@ -1,70 +1,137 @@ namespace ServiceComposition.NET; /// -/// Provides extension methods for working with objects. +/// Provides extension methods for validating service registration expressions +/// used within service composition pipelines. /// +/// +/// These validation methods enforce the structural constraints required by +/// and +/// . +/// +/// A valid service registration expression must: +/// +/// +/// Represent a single method call expression. +/// +/// +/// Invoke an extension method. +/// +/// +/// Target as the extension method's first parameter. +/// +/// +/// +/// Validation occurs at the time an expression is added to a pipeline, +/// ensuring invalid registrations are rejected before execution. +/// public static class ExpressionExtensions { /// /// Validates a service registration expression that requires access to configuration. /// /// - /// The service registration expression to validate. + /// A lambda expression representing a service registration operation. /// + /// + /// Thrown when is . + /// /// - /// Thrown when the expression does not represent a valid service registration call. + /// Thrown when the expression is not a valid service registration call. /// - public static void ValidateServiceRegistration(this Expression> registrationExpression) - { - if (registrationExpression?.Body is not MethodCallExpression methodCallExpression) - { - throw new ArgumentException( - "Registration expression must be a call to a method on IServiceCollection.", - nameof(registrationExpression)); - } + /// + /// The expression must represent a single extension method call on + /// . For example: + /// + /// (services, configuration) => services.AddScoped<IMyService, MyService>() + /// + /// + public static void ValidateServiceRegistration(this Expression>? registrationExpression) => + ValidateCore(registrationExpression); - if (!methodCallExpression.IsValidServiceCollectionExtension()) - { - throw new ArgumentException( - "Only extension methods declared on IServiceCollection are allowed as service registration expressions.", - nameof(registrationExpression)); - } - } + /// + /// Validates a service registration expression that does not require configuration. + /// + /// + /// A lambda expression representing a service registration operation. + /// + /// + /// Thrown when is . + /// + /// + /// Thrown when the expression is not a valid service registration call. + /// + /// + /// The expression must represent a single extension method call on + /// . For example: + /// + /// services => services.AddSingleton<IMyService, MyService>() + /// + /// + public static void ValidateServiceRegistration(this Expression>? registrationExpression) => + ValidateCore(registrationExpression); /// - /// Validates a service registration expression that does not require access to configuration. + /// Performs core validation logic for service registration expressions. /// /// - /// The service registration expression to validate. + /// The lambda expression to validate. /// + /// + /// Thrown when is . + /// /// - /// Thrown when the expression does not represent a valid service registration call. + /// Thrown when the expression is not a single method call targeting a valid + /// extension method. /// - public static void ValidateServiceRegistration(this Expression> registrationExpression) + private static void ValidateCore(LambdaExpression? registrationExpression) { - if (registrationExpression?.Body is not MethodCallExpression methodCallExpression) - { - throw new ArgumentException( - "Registration expression must be a call to a method on IServiceCollection.", - nameof(registrationExpression)); - } + if (registrationExpression is null) + throw new ArgumentNullException(nameof(registrationExpression)); - if (!methodCallExpression.IsValidServiceCollectionExtension()) - { + if (registrationExpression.Body is not MethodCallExpression methodCallExpression) throw new ArgumentException( - "Only extension methods declared on IServiceCollection are allowed as service registration expressions.", + "Registration expression must be a single method call on IServiceCollection.", nameof(registrationExpression)); - } - } - private static bool IsValidServiceCollectionExtension(this MethodCallExpression methodCallExpression) - { - return methodCallExpression.Method.IsExtensionMethod() && - typeof(IServiceCollection).IsAssignableFrom(methodCallExpression.Method.GetParameters()[0].ParameterType); + methodCallExpression.EnsureValidServiceCollectionExtension(); } - private static bool IsExtensionMethod(this MethodInfo methodInfo) + /// + /// Ensures the method call represents a valid extension. + /// + /// + /// The method call expression to validate. + /// + /// + /// Thrown when the method is not an extension method declared for + /// . + /// + private static void EnsureValidServiceCollectionExtension( + this MethodCallExpression methodCallExpression) { - return methodInfo.IsDefined(typeof(System.Runtime.CompilerServices.ExtensionAttribute), false); + var method = methodCallExpression.Method; + + var isAssignableFromIServiceCollection = + method.GetParameters().Length > 0 && + typeof(IServiceCollection).IsAssignableFrom(method.GetParameters()[0].ParameterType); + + var isValid = method.IsExtensionMethod() && isAssignableFromIServiceCollection; + + if (!isValid) + throw new ArgumentException("Only extension methods declared on IServiceCollection are allowed as service registration expressions."); } + + /// + /// Determines whether a method is defined as an extension method. + /// + /// + /// The method to inspect. + /// + /// + /// if the method is marked with ; + /// otherwise, . + /// + internal static bool IsExtensionMethod(this MethodInfo methodInfo) => + methodInfo.IsDefined(typeof(System.Runtime.CompilerServices.ExtensionAttribute), false); } \ No newline at end of file diff --git a/src/ServiceComposition.NET/ServiceRegistrationExpressionCollection.cs b/src/ServiceComposition.NET/ServiceRegistrationExpressionCollection.cs index cca23be..f9765fe 100644 --- a/src/ServiceComposition.NET/ServiceRegistrationExpressionCollection.cs +++ b/src/ServiceComposition.NET/ServiceRegistrationExpressionCollection.cs @@ -6,28 +6,23 @@ /// /// /// This type centralizes the mechanics for collecting, validating, and normalizing -/// service registration expressions without performing any execution. It exists to -/// avoid duplication of expression management behavior across higher-level -/// composition types, such as service registration pipelines and presentation-layer -/// composition roots. +/// service registration expressions without executing them. /// -/// All expressions added to this collection are validated at the point of mutation -/// and normalized into a configuration-aware form -/// (Expression<Action<IServiceCollection, IConfiguration>>). Expressions -/// that do not require configuration are lifted into this canonical shape without -/// being compiled or invoked. +/// Expressions are validated at the point of addition to ensure they represent +/// legitimate extension method calls targeting . +/// Invalid expressions are rejected immediately and never stored. /// /// -/// This class intentionally contains no execution logic and does not assume a -/// particular hosting model, startup lifecycle, or ownership of configuration or -/// service collection instances. Execution semantics are the responsibility of -/// higher-level abstractions. +/// All stored expressions are normalized into a configuration-aware canonical form: +/// Expression<Action<IServiceCollection, IConfiguration>>. +/// Expressions that do not require configuration are lifted into this shape +/// without being compiled or executed. /// /// -/// Expressions managed by this collection are expected to represent service -/// registration operations performed against , -/// such as calls to AddTransient, AddScoped, AddSingleton, or -/// valid extension methods defined for . +/// This class contains no execution logic and does not assume ownership of +/// or instances. +/// Execution semantics are the responsibility of higher-level abstractions, +/// such as . /// /// /// This type is infrastructure support for higher-level composition abstractions @@ -42,32 +37,30 @@ public abstract class ServiceRegistrationExpressionCollection /// Gets the ordered collection of validated service registration expressions. /// /// - /// The returned collection reflects the order in which expressions were added. - /// This ordering is preserved and relied upon by consumers that execute the - /// expressions as part of a larger service composition process. + /// The returned collection preserves the order in which expressions were added. + /// Ordering is significant and relied upon by consumers that execute the + /// collection as part of a service composition pipeline. /// protected IReadOnlyList>> Expressions => _expressions; /// /// Adds a service registration expression that requires access to configuration. /// - /// - /// The provided expression must represent a valid service registration call - /// against , such as AddTransient, - /// AddScoped, AddSingleton, or a custom extension method defined - /// for . - /// - /// Expressions are validated at the point of addition and are not compiled or - /// executed until a higher-level component invokes the service registration - /// pipeline. - /// - /// /// - /// The service registration expression to add. + /// A lambda expression representing a service registration operation. /// + /// + /// Thrown when is . + /// /// - /// Thrown when the expression does not represent a valid service registration call. + /// Thrown when the expression does not represent a valid + /// extension method call. /// + /// + /// The expression must represent a single extension method call targeting + /// . Validation occurs immediately and the + /// expression is not compiled or executed at this stage. + /// public void AddRegistration(Expression> expression) { expression.ValidateServiceRegistration(); @@ -75,25 +68,24 @@ public void AddRegistration(Expression - /// Adds a service registration expression that does not require access to configuration. + /// Adds a service registration expression that does not require configuration. /// - /// - /// The provided expression must represent a valid service registration call - /// against , such as AddTransient, - /// AddScoped, AddSingleton, or a custom extension method defined - /// for . - /// - /// The expression is lifted into a configuration-aware form without being - /// compiled or executed. Execution occurs only when the service registration - /// pipeline is invoked. - /// - /// /// - /// The service registration expression to add. + /// A lambda expression representing a service registration operation. /// + /// + /// Thrown when is . + /// /// - /// Thrown when the expression does not represent a valid service registration call. + /// Thrown when the expression does not represent a valid + /// extension method call. /// + /// + /// The expression is validated and then lifted into a configuration-aware + /// canonical form without being compiled or executed. The lifted expression + /// preserves the original method call and defers execution until a + /// invokes it. + /// public void AddRegistration(Expression> expression) { expression.ValidateServiceRegistration(); @@ -113,16 +105,26 @@ public void AddRegistration(Expression> expression) } /// - /// Adds multiple service registration expressions that require access to configuration. + /// Adds multiple service registration expressions that require configuration. /// /// - /// The collection of service registration expressions to add. + /// A sequence of service registration expressions to add. /// /// - /// Thrown when is null. + /// Thrown when is . /// - public void AddRegistrations(IEnumerable>> expressions) + /// + /// Thrown when any expression in the sequence is invalid. + /// + /// + /// Expressions are validated individually in the order provided. + /// If validation fails for any expression, no further expressions are added. + /// + public void AddRegistrations(IEnumerable>>? expressions) { + if (expressions is null) + throw new ArgumentNullException(nameof(expressions)); + foreach (var expression in expressions) { AddRegistration(expression); @@ -130,23 +132,29 @@ public void AddRegistrations(IEnumerable - /// Adds multiple service registration expressions that do not require access to configuration. + /// Adds multiple service registration expressions that do not require configuration. /// - /// - /// Each expression is lifted into a configuration-aware form without being - /// compiled or executed. - /// /// - /// The collection of service registration expressions to add. + /// A sequence of service registration expressions to add. /// /// - /// Thrown when is null. + /// Thrown when is . + /// + /// + /// Thrown when any expression in the sequence is invalid. /// - public void AddRegistrations(IEnumerable>> expressions) + /// + /// Each expression is validated and lifted into a configuration-aware form. + /// Execution remains deferred until a pipeline invokes the collection. + /// + public void AddRegistrations(IEnumerable>>? expressions) { + if (expressions is null) + throw new ArgumentNullException(nameof(expressions)); + foreach (var expression in expressions) { AddRegistration(expression); } } -} +} \ No newline at end of file diff --git a/src/ServiceComposition.NET/ServiceRegistrationPipeline.cs b/src/ServiceComposition.NET/ServiceRegistrationPipeline.cs index e9c710c..19dbcee 100644 --- a/src/ServiceComposition.NET/ServiceRegistrationPipeline.cs +++ b/src/ServiceComposition.NET/ServiceRegistrationPipeline.cs @@ -20,10 +20,15 @@ public abstract class ServiceRegistrationPipeline : ServiceRegistrationExpressionCollection { /// - /// This property provides access to a static instance of the interface that is used to log - /// startup registrations that occur before the dependency injection (DI) container has been initialized. + /// Gets a logger used during pipeline execution. /// - protected abstract ILogger StartupLogger { get; } + /// + /// This logger is intended for use during service registration execution, + /// when the application's dependency injection container has not yet been built. + /// Implementations are responsible for supplying a logger instance that does not + /// depend on services from the container. + /// + protected abstract ILogger Logger { get; } /// /// Executes the service registration pipeline using the provided service collection @@ -59,13 +64,13 @@ public void Execute(IServiceCollection serviceCollection, IConfiguration configu try { - StartupLogger.LogTrace("'{Expression}' was started...", expressionAsString); + Logger.LogTrace("'{Expression}' was started...", expressionAsString); expression.Compile().Invoke(serviceCollection, configuration); - StartupLogger.LogTrace("'{Expression}' completed successfully!", expressionAsString); + Logger.LogTrace("'{Expression}' completed successfully!", expressionAsString); } catch (Exception exception) { - StartupLogger.LogTrace(exception, "'{Expression}' failed with an unhandled exception.", expressionAsString); + Logger.LogTrace(exception, "'{Expression}' failed with an unhandled exception.", expressionAsString); throw; } } @@ -91,26 +96,24 @@ public void Execute(IServiceCollection serviceCollection, IConfiguration configu protected virtual string GetExpressionAsString(Expression> expression) { var methodCall = (MethodCallExpression)expression.Body; - - // Get the method name + MethodInfo method = methodCall.Method; string methodName = methodCall.Method.Name; // Handle generic arguments (for cases like AddScoped) string genericArgs = string.Empty; - if (methodCall.Method.IsGenericMethod) + if (method.IsGenericMethod) { - var genericArguments = methodCall.Method.GetGenericArguments() + var genericArguments = method.GetGenericArguments() .Select(arg => arg.Name) .ToArray(); genericArgs = $"<{string.Join(", ", genericArguments)}>"; } // Handle normal parameters (for cases like AddScoped(typeof(IMyService), typeof(MyService))) - bool isExtensionMethod = methodCall.Method.IsDefined(typeof(System.Runtime.CompilerServices.ExtensionAttribute), false); var normalArgs = methodCall.Arguments .Select((arg, index) => { - if (index == 0 && isExtensionMethod) + if (index == 0 && method.IsExtensionMethod()) { return $"this {arg.Type.Name}"; } diff --git a/tst/ServiceComposition.NET.IntegrationTests/ServiceComposition.NET.IntegrationTests.csproj b/tst/ServiceComposition.NET.IntegrationTests/ServiceComposition.NET.IntegrationTests.csproj index df7f0b1..a3f3d3d 100644 --- a/tst/ServiceComposition.NET.IntegrationTests/ServiceComposition.NET.IntegrationTests.csproj +++ b/tst/ServiceComposition.NET.IntegrationTests/ServiceComposition.NET.IntegrationTests.csproj @@ -16,6 +16,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/tst/ServiceComposition.NET.IntegrationTests/TestClasses/TestServiceRegistrationPipeline.cs b/tst/ServiceComposition.NET.IntegrationTests/TestClasses/TestServiceRegistrationPipeline.cs index d1b1c27..87548c3 100644 --- a/tst/ServiceComposition.NET.IntegrationTests/TestClasses/TestServiceRegistrationPipeline.cs +++ b/tst/ServiceComposition.NET.IntegrationTests/TestClasses/TestServiceRegistrationPipeline.cs @@ -7,7 +7,7 @@ namespace ServiceComposition.NET.IntegrationTests.TestClasses; internal class TestServiceRegistrationPipeline : ServiceRegistrationPipeline { /// - protected override ILogger StartupLogger => NullLogger.Instance; + protected override ILogger Logger => NullLogger.Instance; public TestServiceRegistrationPipeline() { diff --git a/tst/ServiceComposition.NET.UnitTests/ExpressionExtensionTests.cs b/tst/ServiceComposition.NET.UnitTests/ExpressionExtensionTests.cs index 4eb9860..5798af2 100644 --- a/tst/ServiceComposition.NET.UnitTests/ExpressionExtensionTests.cs +++ b/tst/ServiceComposition.NET.UnitTests/ExpressionExtensionTests.cs @@ -12,17 +12,17 @@ public class ExpressionExtensionTests // ------------------------------------------------------------ [Fact] - public void ValidateServiceRegistration_WithConfiguration_Throws_WhenExpressionIsNotMethodCall() + public void ValidateServiceRegistration_WithConfiguration_Throws_WhenExpressionIsNull() { Expression> registrationExpression = null!; - var ex = Assert.Throws(() => registrationExpression.ValidateServiceRegistration()); + var ex = Assert.Throws(() => registrationExpression.ValidateServiceRegistration()); - Assert.Contains("Registration expression must be a call to a method on IServiceCollection.", ex.Message); + Assert.Contains($"{nameof(registrationExpression)}", ex.Message); } [Fact] - public void ValidateServiceRegistration_WithConfiguration_Throws_WhenMethodIsNotIServiceCollectionExtension() + public void ValidateServiceRegistration_Throws_WhenExtensionDoesNotTargetIServiceCollection() { Expression> registrationExpression = (_, config) => config.ConfigurationExtension(); @@ -31,6 +31,16 @@ public void ValidateServiceRegistration_WithConfiguration_Throws_WhenMethodIsNot Assert.Contains("Only extension methods declared on IServiceCollection are allowed", ex.Message); } + [Fact] + public void ValidateServiceRegistration_Throws_WhenMethodIsInstanceMethod() + { + Expression> expr = (services, config) => services.ToString(); + + var ex = Assert.Throws(() => expr.ValidateServiceRegistration()); + + Assert.Contains("Only extension methods declared on IServiceCollection are allowed", ex.Message); + } + [Fact] public void ValidateServiceRegistration_WithConfiguration_Passes_ForValidServiceCollectionExtensions() { @@ -43,22 +53,44 @@ public void ValidateServiceRegistration_WithConfiguration_Passes_ForValidService Assert.Null(Record.Exception(() => customExtension.ValidateServiceRegistration())); } + [Fact] + public void ValidateServiceRegistration_Throws_WhenExpressionIsInvocation() + { + Expression> inner = + (s, c) => s.AddTransient(); + + var serviceParam = Expression.Parameter(typeof(IServiceCollection), "s"); + var configParam = Expression.Parameter(typeof(IConfiguration), "c"); + + var invocation = Expression.Invoke(inner, serviceParam, configParam); + + var lambda = Expression.Lambda>( + invocation, + serviceParam, + configParam); + + var ex = Assert.Throws(() => + lambda.ValidateServiceRegistration()); + + Assert.Contains("single method call", ex.Message); + } + // ------------------------------------------------------------ // Configuration-free overload // ------------------------------------------------------------ [Fact] - public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenExpressionIsNotMethodCall() + public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenExpressionIsNull() { Expression> registrationExpression = null!; - var ex = Assert.Throws(() => registrationExpression.ValidateServiceRegistration()); + var ex = Assert.Throws(() => registrationExpression.ValidateServiceRegistration()); - Assert.Contains("Registration expression must be a call to a method on IServiceCollection.", ex.Message); + Assert.Contains($"{nameof(registrationExpression)}", ex.Message); } [Fact] - public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenMethodIsNotIServiceCollectionExtension() + public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenExtensionDoesNotTargetIServiceCollection() { Expression> registrationExpression = services => InvalidStaticMethod(); @@ -67,6 +99,16 @@ public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenMethodIs Assert.Contains("Only extension methods declared on IServiceCollection are allowed", ex.Message); } + [Fact] + public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenMethodIsInstanceMethod() + { + Expression> expr = services => services.ToString(); + + var ex = Assert.Throws(() => expr.ValidateServiceRegistration()); + + Assert.Contains("Only extension methods declared on IServiceCollection are allowed", ex.Message); + } + [Fact] public void ValidateServiceRegistration_WithoutConfiguration_Passes_ForValidServiceCollectionExtensions() { @@ -79,6 +121,26 @@ public void ValidateServiceRegistration_WithoutConfiguration_Passes_ForValidServ Assert.Null(Record.Exception(() => customExtension.ValidateServiceRegistration())); } + [Fact] + public void ValidateServiceRegistration_WithoutConfiguration_Throws_WhenExpressionIsInvocation() + { + Expression> inner = + s => s.AddTransient(); + + var parameter = Expression.Parameter(typeof(IServiceCollection), "s"); + + var invocation = Expression.Invoke(inner, parameter); + + var lambda = Expression.Lambda>( + invocation, + parameter); + + var ex = Assert.Throws(() => + lambda.ValidateServiceRegistration()); + + Assert.Contains("single method call", ex.Message); + } + // ------------------------------------------------------------ // Helpers // ------------------------------------------------------------ diff --git a/tst/ServiceComposition.NET.UnitTests/ServiceComposition.NET.UnitTests.csproj b/tst/ServiceComposition.NET.UnitTests/ServiceComposition.NET.UnitTests.csproj index ab712da..539d634 100644 --- a/tst/ServiceComposition.NET.UnitTests/ServiceComposition.NET.UnitTests.csproj +++ b/tst/ServiceComposition.NET.UnitTests/ServiceComposition.NET.UnitTests.csproj @@ -12,6 +12,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationExpressionCollectionTests.cs b/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationExpressionCollectionTests.cs new file mode 100644 index 0000000..29f6b8d --- /dev/null +++ b/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationExpressionCollectionTests.cs @@ -0,0 +1,196 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ServiceComposition.NET.UnitTests; + +public class ServiceRegistrationExpressionCollectionTests +{ + private sealed class TestCollection : ServiceRegistrationExpressionCollection + { + public IReadOnlyList>> GetExpressions() => Expressions; + } + + private interface ITestService; + private class TestService : ITestService; + + #region AddRegistration (Configuration Aware) + + [Fact] + public void AddRegistration_WithConfiguration_AddsExpression() + { + var collection = new TestCollection(); + + Expression> expr = + (services, config) => services.AddScoped(); + + collection.AddRegistration(expr); + + Assert.Single(collection.GetExpressions()); + } + + [Fact] + public void AddRegistration_WithConfiguration_Null_Throws() + { + var collection = new TestCollection(); + + Assert.Throws(() => + collection.AddRegistration((Expression>)null!)); + } + + #endregion + + #region AddRegistration (Without Configuration) + + [Fact] + public void AddRegistration_WithoutConfiguration_AddsLiftedExpression() + { + var collection = new TestCollection(); + + Expression> expr = + services => services.AddSingleton(); + + collection.AddRegistration(expr); + + var stored = collection.GetExpressions().Single(); + + Assert.NotNull(stored); + Assert.Equal(2, stored.Parameters.Count); + Assert.Equal(typeof(IServiceCollection), stored.Parameters[0].Type); + Assert.Equal(typeof(IConfiguration), stored.Parameters[1].Type); + } + + [Fact] + public void AddRegistration_WithoutConfiguration_Null_Throws() + { + var collection = new TestCollection(); + + Assert.Throws(() => + collection.AddRegistration((Expression>)null!)); + } + + #endregion + + #region AddRegistrations (Configuration Aware) + + [Fact] + public void AddRegistrations_WithConfiguration_AddsAllInOrder() + { + var collection = new TestCollection(); + + var expr1 = (Expression>) + ((s, c) => s.AddScoped()); + + var expr2 = (Expression>) + ((s, c) => s.AddSingleton()); + + collection.AddRegistrations([expr1, expr2]); + + var stored = collection.GetExpressions(); + + Assert.Equal(2, stored.Count); + Assert.Same(expr1, stored[0]); + Assert.Same(expr2, stored[1]); + } + + [Fact] + public void AddRegistrations_WithConfiguration_NullCollection_Throws() + { + var collection = new TestCollection(); + + Assert.Throws(() => + collection.AddRegistrations((IEnumerable>>)null!)); + } + + #endregion + + #region AddRegistrations (Without Configuration) + + [Fact] + public void AddRegistrations_WithoutConfiguration_AddsAllLifted() + { + var collection = new TestCollection(); + + var expr1 = (Expression>) + (s => s.AddScoped()); + + var expr2 = (Expression>) + (s => s.AddSingleton()); + + collection.AddRegistrations([expr1, expr2]); + + var stored = collection.GetExpressions(); + + Assert.Equal(2, stored.Count); + Assert.All(stored, e => Assert.Equal(2, e.Parameters.Count)); + } + + [Fact] + public void AddRegistrations_WithoutConfiguration_NullCollection_Throws() + { + var collection = new TestCollection(); + + Assert.Throws(() => + collection.AddRegistrations((IEnumerable>>)null!)); + } + + #endregion + + #region Validation Behavior + + [Fact] + public void AddRegistration_InvalidTargetMethod_Throws() + { + var collection = new TestCollection(); + + Expression> invalid = + services => services.ToString(); // Not IServiceCollection extension + + Assert.Throws(() => + collection.AddRegistration(invalid)); + } + + [Fact] + public void AddRegistrations_StopsOnFirstInvalidExpression() + { + var collection = new TestCollection(); + + var valid = (Expression>) + (s => s.AddScoped()); + + var invalid = (Expression>) + (s => s.ToString()); + + Assert.Throws(() => + collection.AddRegistrations([valid, invalid])); + + // Only the first valid one should have been added + Assert.Single(collection.GetExpressions()); + } + + #endregion + + #region Ordering Guarantee + + [Fact] + public void Expressions_PreserveInsertionOrder() + { + var collection = new TestCollection(); + + var expr1 = (Expression>) + (s => s.AddScoped()); + + var expr2 = (Expression>) + (s => s.AddSingleton()); + + collection.AddRegistration(expr1); + collection.AddRegistration(expr2); + + var stored = collection.GetExpressions(); + + Assert.Equal(2, stored.Count); + Assert.Equal("AddScoped", ((MethodCallExpression)stored[0].Body).Method.Name); + Assert.Equal("AddSingleton", ((MethodCallExpression)stored[1].Body).Method.Name); + } + + #endregion +} \ No newline at end of file diff --git a/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationPipelineTests.cs b/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationPipelineTests.cs index c59666f..2712251 100644 --- a/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationPipelineTests.cs +++ b/tst/ServiceComposition.NET.UnitTests/ServiceRegistrationPipelineTests.cs @@ -27,7 +27,7 @@ public void AddRegistration_Throws_WhenExpressionIs_InvalidMethodCall() var pipeline = new TestServiceRegistrationPipeline(); var ex = Assert.Throws(() => pipeline.AddRegistration((x, y) => Expression.Empty())); - var ex2 = Assert.Throws(() => pipeline.AddRegistration((x) => Expression.Empty())); + var ex2 = Assert.Throws(() => pipeline.AddRegistration(x => Expression.Empty())); var expectedMessage = "Only extension methods declared on IServiceCollection are allowed as service registration expressions."; Assert.Contains(expectedMessage, ex.Message); @@ -46,7 +46,7 @@ public void RegisterServices_Throws_WhenExpression_Fails() } [Fact] - public void Execute_LogsSuccess_WithStartupLogger() + public void Execute_LogsSuccess_WithLogger() { var serviceCollection = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); @@ -76,7 +76,7 @@ public void Execute_LogsSuccess_WithStartupLogger() } [Fact] - public void Execute_LogsFailure_WithStartupLogger() + public void Execute_LogsFailure_WithLogger() { var serviceCollection = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); @@ -223,4 +223,83 @@ public void Execute_Throws_When_ServiceCollection_IsNull() Assert.Throws(() => pipeline.Execute(null!, configuration)); } + + [Fact] + public void Execute_LogsServiceRegistration_WithMethodCallArgument() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + var pipeline = new TestServiceRegistrationPipelineWithLogger(); + + Expression> expression = + (s, c) => s.AddWithMethodCall(MethodCallArgumentExtensions.GetValue()); + + pipeline.AddRegistration(expression); + + var expected = "'AddWithMethodCall(this IServiceCollection, String)' was started..."; + + pipeline.Execute(services, configuration); + + pipeline.GetLogger().Verify(logger => logger.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((o, t) => + o.ToString()!.Contains(expected)), + It.IsAny(), + It.IsAny>()!), + Times.Once); + } + + [Fact] + public void Execute_LogsServiceRegistration_WithTypeBinaryExpression() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + var pipeline = new TestServiceRegistrationPipelineWithLogger(); + + object obj = "hello"; + + Expression> expression = + (s, c) => s.AddWithTypeCheck(obj is string); + + pipeline.AddRegistration(expression); + + var expected = "'AddWithTypeCheck(this IServiceCollection, Boolean)' was started..."; + + pipeline.Execute(services, configuration); + + pipeline.GetLogger().Verify(logger => logger.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((o, t) => + o.ToString()!.Contains(expected)), + It.IsAny(), + It.IsAny>()!), + Times.Once); + } +} + +internal static class MethodCallArgumentExtensions +{ + public static IServiceCollection AddWithMethodCall( + this IServiceCollection services, + string value) + { + return services; + } + + public static string GetValue() + { + return "value"; + } +} + +internal static class TypeBinaryArgumentExtensions +{ + public static IServiceCollection AddWithTypeCheck( + this IServiceCollection services, + bool condition) + { + return services; + } } \ No newline at end of file diff --git a/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipeline.cs b/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipeline.cs index ad973d8..031d679 100644 --- a/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipeline.cs +++ b/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipeline.cs @@ -8,7 +8,7 @@ namespace ServiceComposition.NET.UnitTests.TestClasses; internal sealed class TestServiceRegistrationPipeline : ServiceRegistrationPipeline { - protected override ILogger StartupLogger => NullLogger.Instance; + protected override ILogger Logger => NullLogger.Instance; public TestServiceRegistrationPipeline() { diff --git a/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipelineWithLogger.cs b/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipelineWithLogger.cs index 4d94771..5dcd8cc 100644 --- a/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipelineWithLogger.cs +++ b/tst/ServiceComposition.NET.UnitTests/TestClasses/TestServiceRegistrationPipelineWithLogger.cs @@ -6,7 +6,7 @@ internal sealed class TestServiceRegistrationPipelineWithLogger : ServiceRegistr { private readonly Mock _mockLogger = new(); - protected override ILogger StartupLogger => _mockLogger.Object; + protected override ILogger Logger => _mockLogger.Object; internal Mock GetLogger() {