From d1f7372ec7cba067791bf81bc5d6bb950aa8ca40 Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Fri, 19 Jun 2026 12:57:58 +0300 Subject: [PATCH] feat: upgrade to .NET 10 and add async chains, context-aware routing, keyed DI, and tracing Target net10.0 across the library, tests, and all examples; bump Microsoft.Extensions.* to 10.0.9 and the package version to 1.1.0. New features (each with tests and docs): - Async adapter chains (IAsyncAdapter, AddAsyncAdapterChain) for non-blocking I/O pipelines - Context-aware resolution: IRelayResolver flows an explicit IRelayContext into conditional relays via IRelayContextAccessor; RelayFactoryBuilder.SelectKeyByContext - Native keyed DI: AddKeyedRelay and RelayFactoryBuilder.RegisterKeyedRelay - Built-in tracing via RelayDiagnostics.ActivitySource on chains and multi-relays Fixes and cleanup: - Implement AdapterChainFactory properly with named chains (AddAdapterChainFactory); it was previously an orphaned stub that ignored the chain name - Cache Adapt/AdaptAsync reflection per adapter type; lock-free round-robin in MultiRelay - README: remove docs for nonexistent APIs, add missing .Build() calls, refresh version and .NET requirement --- .github/workflows/dotnet.yml | 2 +- Directory.Packages.props | 12 +- README.md | 142 +++++++++---- Relay.sln | 146 ++++++++++++- .../Relay.Examples.Adapter.csproj | 2 +- .../Relay.Examples.AdapterChain.csproj | 2 +- .../Relay.Examples.AdapterFactory.csproj | 2 +- .../Relay.Examples.AdvancedDecorator.csproj | 2 +- examples/Relay.Examples.AsyncChain/Program.cs | 52 +++++ .../Relay.Examples.AsyncChain.csproj | 15 ++ .../Relay.Examples.Basic.csproj | 2 +- .../Relay.Examples.Conditional.csproj | 2 +- .../Relay.Examples.ConditionalRouting.csproj | 2 +- .../Relay.Examples.DecorateWith.csproj | 2 +- .../Relay.Examples.Factory.csproj | 2 +- .../Relay.Examples.Integration.csproj | 2 +- .../Relay.Examples.MultiBroadcasting.csproj | 2 +- .../Relay.Examples.MultiFailover.csproj | 2 +- .../Relay.Examples.ParallelWithResults.csproj | 2 +- global.json | 4 +- src/Builders/AdapterChainFactoryBuilder.cs | 182 +++++++++++++++++ src/Builders/AsyncAdapterChainBuilder.cs | 137 +++++++++++++ src/Builders/ConditionalRelayBuilder.cs | 6 +- src/Builders/RelayFactoryBuilder.cs | 42 +++- src/Core/Implementations/AdapterChain.cs | 68 ++++-- .../Implementations/AdapterChainFactory.cs | 22 +- src/Core/Implementations/AsyncAdapterChain.cs | 110 ++++++++++ src/Core/Implementations/MultiRelay.cs | 20 +- .../Implementations/RelayContextAccessor.cs | 12 ++ src/Core/Implementations/RelayFactory.cs | 10 +- src/Core/Implementations/RelayResolver.cs | 20 +- src/Core/Interfaces/IAsyncAdapterChain.cs | 90 ++++++++ src/Core/Interfaces/IRelayContextAccessor.cs | 14 ++ src/Diagnostics/RelayDiagnostics.cs | 32 +++ src/Relay.csproj | 4 +- src/ServiceCollectionExtensions.cs | 51 +++++ .../Features/AdapterChainFactoryTests.cs | 111 ++++++++++ .../Relay.Tests/Features/NewFeaturesTests.cs | 193 ++++++++++++++++++ tests/Relay.Tests/Relay.Tests.csproj | 2 +- 39 files changed, 1416 insertions(+), 109 deletions(-) create mode 100644 examples/Relay.Examples.AsyncChain/Program.cs create mode 100644 examples/Relay.Examples.AsyncChain/Relay.Examples.AsyncChain.csproj create mode 100644 src/Builders/AdapterChainFactoryBuilder.cs create mode 100644 src/Builders/AsyncAdapterChainBuilder.cs create mode 100644 src/Core/Implementations/AsyncAdapterChain.cs create mode 100644 src/Core/Implementations/RelayContextAccessor.cs create mode 100644 src/Core/Interfaces/IAsyncAdapterChain.cs create mode 100644 src/Core/Interfaces/IRelayContextAccessor.cs create mode 100644 src/Diagnostics/RelayDiagnostics.cs create mode 100644 tests/Relay.Tests/Features/AdapterChainFactoryTests.cs create mode 100644 tests/Relay.Tests/Features/NewFeaturesTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7fcd6dc..7d03f06 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,7 +19,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/Directory.Packages.props b/Directory.Packages.props index 41bf739..e1adfd3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,12 +4,12 @@ - - - - - - + + + + + + diff --git a/README.md b/README.md index 2c7e018..a7f4a48 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,10 @@ Transform the adapter pattern from a simple design pattern into a **powerful arc - **πŸ“‘ Multi-Relay Broadcasting**: Execute operations across multiple implementations with various strategies - **πŸ”— True Adapter Pattern**: Seamlessly integrate legacy systems and incompatible interfaces - **⛓️ Adapter Chains**: Build complex transformation pipelines (Aβ†’Bβ†’Cβ†’X) -- **🏭 Relay Factories**: Key-based service creation with flexible configuration -- **⚑ Performance Optimized**: Efficient resolution with comprehensive lifetime management +- **🏭 Relay Factories**: Key-based service creation with native keyed-DI support +- **⏱️ Async Pipelines**: `IAsyncAdapter` chains for non-blocking I/O transformations +- **πŸ”­ Observability**: Built-in `ActivitySource` tracing for chains and multi-relays +- **⚑ Performance Optimized**: Cached reflection and lock-free round-robin resolution ## πŸš€ **Quick Start** @@ -38,6 +40,8 @@ Transform the adapter pattern from a simple design pattern into a **powerful arc dotnet add package Relay ``` +> **Requirements:** .NET 10.0 or later. + ### Basic Usage ```csharp using Relay; @@ -47,7 +51,8 @@ services.AddRelayServices(); // Basic relay services.AddRelay() - .WithScopedLifetime(); + .WithScopedLifetime() + .Build(); // Conditional relay services.AddConditionalRelay() @@ -69,7 +74,8 @@ services.AddMultiRelay(config => config ```csharp services.AddRelay() .WithScopedLifetime() - .DecorateWith(); + .DecorateWith() + .Build(); ``` ### **2. Conditional Routing** @@ -107,7 +113,7 @@ services.AddMultiRelay(config => config ### **4. True Adapter Pattern** ```csharp -// Your RefactoringGuru example becomes: +// Wrap an incompatible service in an adapter services.AddAdapter() .WithScopedLifetime() .Using(); @@ -139,6 +145,21 @@ services.AddTypedAdapterChain() .Then() .Then() .Build(); + +// Named chains β€” several pipelines producing the same result, picked by name at runtime. +// The source instance is resolved from the container. +services.AddSingleton(new RawData(...)); +services.AddAdapterChainFactory() + .AddChain("full") + .From().Then().Finally() + .AddChain("fast") + .From().Finally() + .AddChain("mock", _ => new MockSettings()) // or a plain producer delegate + .Build(); + +var factory = serviceProvider.GetRequiredService>(); +var settings = factory.CreateFromChain("full"); +var names = factory.GetAvailableChains(); // ["full", "fast", "mock"] ``` ### **6. Relay Factory** @@ -179,6 +200,72 @@ services.AddMultiRelay(config => config ).Build(); ``` +### **9. Async Adapter Chains** +For transformation pipelines that perform I/O (HTTP, database, file access), use async adapters so steps never block a thread. +```csharp +public class FetchAdapter : IAsyncAdapter +{ + public async Task AdaptAsync(OrderId id, CancellationToken ct = default) + => await _api.GetOrderAsync(id, ct); +} + +services.AddAsyncAdapterChain() + .From() + .Then() // OrderId β†’ OrderDto (async I/O) + .Then() + .Finally() + .Build(); + +// Usage +var chain = serviceProvider.GetRequiredService>(); +var invoice = await chain.ExecuteAsync(new OrderId(42), cancellationToken); +``` + +### **10. Context-Aware Resolution** +`IRelayResolver` now flows an explicit `IRelayContext` into conditional relays and factories, so routing decisions can be made per call. +```csharp +services.AddRelayServices(); +services.AddConditionalRelay() + .When(ctx => (string)ctx.Properties["tier"] == "premium").RelayTo() + .Otherwise() + .Build(); + +var resolver = scope.ServiceProvider.GetRequiredService(); +var ctx = new DefaultRelayContext(scope.ServiceProvider); +ctx.Properties["tier"] = "premium"; +var payment = resolver.Resolve(ctx); // β†’ PremiumPayment + +// Factories can pick a key from the context too +services.AddRelayFactory(f => f + .RegisterKeyedRelay("stripe") + .RegisterKeyedRelay("paypal") + .SelectKeyByContext(c => (string)c.Properties["provider"]) + .SetDefaultRelay("stripe") +).Build(); +var svc = factory.CreateRelay(ctx); // key chosen from ctx.Properties["provider"] +``` + +### **11. Native Keyed Services** +Register relays against a service key using built-in .NET keyed DI β€” resolve them with `[FromKeyedServices]` or `GetRequiredKeyedService`. +```csharp +services.AddKeyedRelay("stripe"); +services.AddKeyedRelay("paypal"); + +// Resolve directly from the container +var stripe = serviceProvider.GetRequiredKeyedService("stripe"); + +// ...or inject by key +public class CheckoutController([FromKeyedServices("paypal")] IPaymentService payment) { } +``` + +### **12. Built-in Observability** +Adapter chains and multi-relays emit `System.Diagnostics.Activity` traces from an `ActivitySource` named `Relay`. Activities are only created when a listener is attached, so overhead is zero otherwise. +```csharp +// OpenTelemetry +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource(RelayDiagnostics.SourceName)); +``` + ## 🎯 **Real-World Use Cases** ### **1. Multi-Environment Deployments** @@ -206,43 +293,6 @@ services.AddMultiRelay(config => config - Logging (Console, File, Database, Cloud) - Caching (Memory, Redis, Database) -## πŸ”§ **Advanced Features** - -### **Conditional Chain Selection** -```csharp -services.AddConditionalAdapterChain() - .When(ctx => ctx.Environment == "Development") - .UseChain(chain => chain - .From() - .Finally()) - .When(ctx => ctx.Environment == "Production") - .UseChain(chain => chain - .From() - .Then() - .Then, OrmToRepositoryAdapter>() - .Finally()) - .Build(); -``` - -### **Parallel Chain Execution** -```csharp -services.AddParallelAdapterChains() - .AddChain("fast", chain => chain - .From() - .Then() - .Finally()) - .AddChain("thorough", chain => chain - .From() - .Then() - .Then() - .Finally()) - .Build(); - -// Usage -var chainFactory = serviceProvider.GetService>(); -var processor = chainFactory.CreateFromChain("thorough"); -``` - ## πŸ—οΈ **Architecture Benefits** ### **βœ… Clean Separation of Concerns** @@ -263,7 +313,7 @@ var processor = chainFactory.CreateFromChain("thorough"); ### **βœ… Enterprise Ready** - Thread-safe operations - Comprehensive error handling -- Extensive logging support +- Built-in distributed tracing via `ActivitySource` ## πŸ§ͺ **Testing Support** @@ -288,7 +338,7 @@ Install-Package Relay dotnet add package Relay # PackageReference - + ``` ### **Basic Setup** @@ -310,7 +360,9 @@ public void ConfigureServices(IServiceCollection services) ## πŸ“š **Documentation** -todo: +Runnable samples for every feature live in the [`examples/`](examples) directory β€” including +basic relays, conditional routing, multi-relay strategies, adapter chains, async chains, and +factories. The [`tests/`](tests) project doubles as executable documentation of the full API. ## 🀝 **Contributing** diff --git a/Relay.sln b/Relay.sln index f0d1ab9..59b0d74 100644 --- a/Relay.sln +++ b/Relay.sln @@ -34,75 +34,219 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.AdapterChain EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9C87688A-802E-46C4-A479-8A4CC1AB58AC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.AsyncChain", "examples\Relay.Examples.AsyncChain\Relay.Examples.AsyncChain.csproj", "{F4D37684-04FF-4FF5-BD76-81CDBEC00064}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x64.ActiveCfg = Debug|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x64.Build.0 = Debug|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x86.ActiveCfg = Debug|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x86.Build.0 = Debug|Any CPU {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|Any CPU.ActiveCfg = Release|Any CPU {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|Any CPU.Build.0 = Release|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x64.ActiveCfg = Release|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x64.Build.0 = Release|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x86.ActiveCfg = Release|Any CPU + {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x86.Build.0 = Release|Any CPU {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x64.Build.0 = Debug|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x86.Build.0 = Debug|Any CPU {7C77897A-8967-431D-9E06-200F5C11628C}.Release|Any CPU.ActiveCfg = Release|Any CPU {7C77897A-8967-431D-9E06-200F5C11628C}.Release|Any CPU.Build.0 = Release|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x64.ActiveCfg = Release|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x64.Build.0 = Release|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x86.ActiveCfg = Release|Any CPU + {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x86.Build.0 = Release|Any CPU {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x64.ActiveCfg = Debug|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x64.Build.0 = Debug|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x86.ActiveCfg = Debug|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x86.Build.0 = Debug|Any CPU {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|Any CPU.ActiveCfg = Release|Any CPU {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|Any CPU.Build.0 = Release|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x64.ActiveCfg = Release|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x64.Build.0 = Release|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x86.ActiveCfg = Release|Any CPU + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x86.Build.0 = Release|Any CPU {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x64.ActiveCfg = Debug|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x64.Build.0 = Debug|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x86.ActiveCfg = Debug|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x86.Build.0 = Debug|Any CPU {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|Any CPU.ActiveCfg = Release|Any CPU {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|Any CPU.Build.0 = Release|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x64.ActiveCfg = Release|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x64.Build.0 = Release|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x86.ActiveCfg = Release|Any CPU + {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x86.Build.0 = Release|Any CPU {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x64.Build.0 = Debug|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x86.Build.0 = Debug|Any CPU {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|Any CPU.ActiveCfg = Release|Any CPU {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|Any CPU.Build.0 = Release|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x64.ActiveCfg = Release|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x64.Build.0 = Release|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x86.ActiveCfg = Release|Any CPU + {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x86.Build.0 = Release|Any CPU {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|Any CPU.Build.0 = Debug|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x64.ActiveCfg = Debug|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x64.Build.0 = Debug|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x86.ActiveCfg = Debug|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x86.Build.0 = Debug|Any CPU {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|Any CPU.ActiveCfg = Release|Any CPU {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|Any CPU.Build.0 = Release|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x64.ActiveCfg = Release|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x64.Build.0 = Release|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x86.ActiveCfg = Release|Any CPU + {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x86.Build.0 = Release|Any CPU {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x64.Build.0 = Debug|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x86.ActiveCfg = Debug|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x86.Build.0 = Debug|Any CPU {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|Any CPU.Build.0 = Release|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x64.ActiveCfg = Release|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x64.Build.0 = Release|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x86.ActiveCfg = Release|Any CPU + {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x86.Build.0 = Release|Any CPU {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x64.Build.0 = Debug|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x86.Build.0 = Debug|Any CPU {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|Any CPU.Build.0 = Release|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x64.ActiveCfg = Release|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x64.Build.0 = Release|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x86.ActiveCfg = Release|Any CPU + {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x86.Build.0 = Release|Any CPU {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x64.ActiveCfg = Debug|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x64.Build.0 = Debug|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x86.Build.0 = Debug|Any CPU {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|Any CPU.ActiveCfg = Release|Any CPU {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|Any CPU.Build.0 = Release|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x64.ActiveCfg = Release|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x64.Build.0 = Release|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x86.ActiveCfg = Release|Any CPU + {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x86.Build.0 = Release|Any CPU {797A2714-3414-4249-A1B6-D0132422E075}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {797A2714-3414-4249-A1B6-D0132422E075}.Debug|Any CPU.Build.0 = Debug|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x64.ActiveCfg = Debug|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x64.Build.0 = Debug|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x86.ActiveCfg = Debug|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x86.Build.0 = Debug|Any CPU {797A2714-3414-4249-A1B6-D0132422E075}.Release|Any CPU.ActiveCfg = Release|Any CPU {797A2714-3414-4249-A1B6-D0132422E075}.Release|Any CPU.Build.0 = Release|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Release|x64.ActiveCfg = Release|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Release|x64.Build.0 = Release|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Release|x86.ActiveCfg = Release|Any CPU + {797A2714-3414-4249-A1B6-D0132422E075}.Release|x86.Build.0 = Release|Any CPU {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x64.Build.0 = Debug|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x86.Build.0 = Debug|Any CPU {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|Any CPU.Build.0 = Release|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x64.ActiveCfg = Release|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x64.Build.0 = Release|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x86.ActiveCfg = Release|Any CPU + {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x86.Build.0 = Release|Any CPU {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x64.ActiveCfg = Debug|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x64.Build.0 = Debug|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x86.ActiveCfg = Debug|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x86.Build.0 = Debug|Any CPU {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|Any CPU.ActiveCfg = Release|Any CPU {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|Any CPU.Build.0 = Release|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x64.ActiveCfg = Release|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x64.Build.0 = Release|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x86.ActiveCfg = Release|Any CPU + {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x86.Build.0 = Release|Any CPU {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x64.Build.0 = Debug|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x86.Build.0 = Debug|Any CPU {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|Any CPU.Build.0 = Release|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x64.ActiveCfg = Release|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x64.Build.0 = Release|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x86.ActiveCfg = Release|Any CPU + {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x86.Build.0 = Release|Any CPU {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x64.ActiveCfg = Debug|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x64.Build.0 = Debug|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x86.ActiveCfg = Debug|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x86.Build.0 = Debug|Any CPU {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|Any CPU.ActiveCfg = Release|Any CPU {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|Any CPU.Build.0 = Release|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x64.ActiveCfg = Release|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x64.Build.0 = Release|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x86.ActiveCfg = Release|Any CPU + {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x86.Build.0 = Release|Any CPU {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x64.Build.0 = Debug|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x86.Build.0 = Debug|Any CPU {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|Any CPU.Build.0 = Release|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x64.ActiveCfg = Release|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x64.Build.0 = Release|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x86.ActiveCfg = Release|Any CPU + {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x86.Build.0 = Release|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x64.Build.0 = Debug|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x86.Build.0 = Debug|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|Any CPU.Build.0 = Release|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x64.ActiveCfg = Release|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x64.Build.0 = Release|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x86.ActiveCfg = Release|Any CPU + {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {7C77897A-8967-431D-9E06-200F5C11628C} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} + {537B681F-5B1A-4E36-9EED-8CC30E5D2E81} = {9C87688A-802E-46C4-A479-8A4CC1AB58AC} {76989990-FEE3-4C8F-82B5-2FC52617C676} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} {312AF4B0-A25B-4B70-AD8D-15325BBC9459} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} @@ -115,6 +259,6 @@ Global {B7D20116-EB4A-4207-8C02-2C9F4728FA5F} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} {242EB29B-65C0-4B1F-A496-5BF256201D6B} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} {A1DCFE8F-9EA6-42A8-9ECD-770592075235} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81} = {9C87688A-802E-46C4-A479-8A4CC1AB58AC} + {F4D37684-04FF-4FF5-BD76-81CDBEC00064} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} EndGlobalSection EndGlobal diff --git a/examples/Relay.Examples.Adapter/Relay.Examples.Adapter.csproj b/examples/Relay.Examples.Adapter/Relay.Examples.Adapter.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.Adapter/Relay.Examples.Adapter.csproj +++ b/examples/Relay.Examples.Adapter/Relay.Examples.Adapter.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.AdapterChain/Relay.Examples.AdapterChain.csproj b/examples/Relay.Examples.AdapterChain/Relay.Examples.AdapterChain.csproj index 9e2152e..692dcc5 100644 --- a/examples/Relay.Examples.AdapterChain/Relay.Examples.AdapterChain.csproj +++ b/examples/Relay.Examples.AdapterChain/Relay.Examples.AdapterChain.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.AdapterFactory/Relay.Examples.AdapterFactory.csproj b/examples/Relay.Examples.AdapterFactory/Relay.Examples.AdapterFactory.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.AdapterFactory/Relay.Examples.AdapterFactory.csproj +++ b/examples/Relay.Examples.AdapterFactory/Relay.Examples.AdapterFactory.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.AdvancedDecorator/Relay.Examples.AdvancedDecorator.csproj b/examples/Relay.Examples.AdvancedDecorator/Relay.Examples.AdvancedDecorator.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.AdvancedDecorator/Relay.Examples.AdvancedDecorator.csproj +++ b/examples/Relay.Examples.AdvancedDecorator/Relay.Examples.AdvancedDecorator.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.AsyncChain/Program.cs b/examples/Relay.Examples.AsyncChain/Program.cs new file mode 100644 index 0000000..38e6c1d --- /dev/null +++ b/examples/Relay.Examples.AsyncChain/Program.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay; +using Relay.Core.Interfaces; +using Relay.Examples.AsyncChain; + +// Demonstrates an asynchronous adapter chain: OrderId -> OrderDto -> Invoice. +// Each step performs (simulated) async I/O via IAsyncAdapter, so no thread is blocked. + +var services = new ServiceCollection(); +services + .AddAsyncAdapterChain() + .From() + .Then() + .Finally() + .Build(); + +var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var chain = scope.ServiceProvider.GetRequiredService>(); +var invoice = await chain.ExecuteAsync(new OrderId(42)); +Console.WriteLine(invoice); + +namespace Relay.Examples.AsyncChain +{ + public record OrderId(int Value); + + public record OrderDto(int Id, decimal Amount); + + public record Invoice(int OrderId, string Total) + { + public override string ToString() => $"Invoice for order {OrderId}: {Total}"; + } + + public class FetchOrderAdapter : IAsyncAdapter + { + public async Task AdaptAsync(OrderId source, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); // simulate an API/database call + return new OrderDto(source.Value, 199.99m); + } + } + + public class InvoiceAdapter : IAsyncAdapter + { + public async Task AdaptAsync(OrderDto source, CancellationToken cancellationToken = default) + { + await Task.Delay(50, cancellationToken); // simulate rendering/persisting + return new Invoice(source.Id, $"{source.Amount:C}"); + } + } +} diff --git a/examples/Relay.Examples.AsyncChain/Relay.Examples.AsyncChain.csproj b/examples/Relay.Examples.AsyncChain/Relay.Examples.AsyncChain.csproj new file mode 100644 index 0000000..1593aa5 --- /dev/null +++ b/examples/Relay.Examples.AsyncChain/Relay.Examples.AsyncChain.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/examples/Relay.Examples.Basic/Relay.Examples.Basic.csproj b/examples/Relay.Examples.Basic/Relay.Examples.Basic.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.Basic/Relay.Examples.Basic.csproj +++ b/examples/Relay.Examples.Basic/Relay.Examples.Basic.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.Conditional/Relay.Examples.Conditional.csproj b/examples/Relay.Examples.Conditional/Relay.Examples.Conditional.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.Conditional/Relay.Examples.Conditional.csproj +++ b/examples/Relay.Examples.Conditional/Relay.Examples.Conditional.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.ConditionalRouting/Relay.Examples.ConditionalRouting.csproj b/examples/Relay.Examples.ConditionalRouting/Relay.Examples.ConditionalRouting.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.ConditionalRouting/Relay.Examples.ConditionalRouting.csproj +++ b/examples/Relay.Examples.ConditionalRouting/Relay.Examples.ConditionalRouting.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.DecorateWith/Relay.Examples.DecorateWith.csproj b/examples/Relay.Examples.DecorateWith/Relay.Examples.DecorateWith.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.DecorateWith/Relay.Examples.DecorateWith.csproj +++ b/examples/Relay.Examples.DecorateWith/Relay.Examples.DecorateWith.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.Factory/Relay.Examples.Factory.csproj b/examples/Relay.Examples.Factory/Relay.Examples.Factory.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.Factory/Relay.Examples.Factory.csproj +++ b/examples/Relay.Examples.Factory/Relay.Examples.Factory.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.Integration/Relay.Examples.Integration.csproj b/examples/Relay.Examples.Integration/Relay.Examples.Integration.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.Integration/Relay.Examples.Integration.csproj +++ b/examples/Relay.Examples.Integration/Relay.Examples.Integration.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.MultiBroadcasting/Relay.Examples.MultiBroadcasting.csproj b/examples/Relay.Examples.MultiBroadcasting/Relay.Examples.MultiBroadcasting.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.MultiBroadcasting/Relay.Examples.MultiBroadcasting.csproj +++ b/examples/Relay.Examples.MultiBroadcasting/Relay.Examples.MultiBroadcasting.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.MultiFailover/Relay.Examples.MultiFailover.csproj b/examples/Relay.Examples.MultiFailover/Relay.Examples.MultiFailover.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.MultiFailover/Relay.Examples.MultiFailover.csproj +++ b/examples/Relay.Examples.MultiFailover/Relay.Examples.MultiFailover.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/examples/Relay.Examples.ParallelWithResults/Relay.Examples.ParallelWithResults.csproj b/examples/Relay.Examples.ParallelWithResults/Relay.Examples.ParallelWithResults.csproj index d2621ed..bca0127 100644 --- a/examples/Relay.Examples.ParallelWithResults/Relay.Examples.ParallelWithResults.csproj +++ b/examples/Relay.Examples.ParallelWithResults/Relay.Examples.ParallelWithResults.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable enable diff --git a/global.json b/global.json index 93681ff..fe7e453 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "9.0.0", - "rollForward": "latestMinor", + "version": "10.0.100", + "rollForward": "latestFeature", "allowPrerelease": false } } \ No newline at end of file diff --git a/src/Builders/AdapterChainFactoryBuilder.cs b/src/Builders/AdapterChainFactoryBuilder.cs new file mode 100644 index 0000000..c1608c5 --- /dev/null +++ b/src/Builders/AdapterChainFactoryBuilder.cs @@ -0,0 +1,182 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay.Core.Implementations; +using Relay.Core.Interfaces; + +namespace Relay.Builders; + +/// +/// Builder for an that exposes several named +/// adapter chains, all producing the same . +/// +public sealed class AdapterChainFactoryBuilder(IServiceCollection services) + where TTarget : class +{ + private readonly IServiceCollection _services = + services ?? throw new ArgumentNullException(nameof(services)); + + private readonly Dictionary> _chains = new(); + + private ServiceLifetime _lifetime = ServiceLifetime.Scoped; + + internal IServiceCollection Services => _services; + + /// + /// Register a named chain expressed directly as a producer of . + /// + public AdapterChainFactoryBuilder AddChain( + string name, + Func producer + ) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Chain name cannot be null or empty", nameof(name)); + } + + ArgumentNullException.ThrowIfNull(producer); + _chains[name] = producer; + return this; + } + + /// + /// Register a named chain built from a sequence of adapters. The source instance is + /// resolved from the container when the chain is created. + /// + public NamedAdapterChainSource AddChain(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Chain name cannot be null or empty", nameof(name)); + } + + return new NamedAdapterChainSource(this, name); + } + + /// + /// Set the lifetime of the registered . + /// + public AdapterChainFactoryBuilder WithLifetime(ServiceLifetime lifetime) + { + _lifetime = lifetime; + return this; + } + + internal void AddChainProducer(string name, Func producer) => + _chains[name] = producer; + + public IServiceCollection Build() + { + _services.Add( + new ServiceDescriptor( + typeof(IAdapterChainFactory), + provider => new AdapterChainFactory(_chains, provider), + _lifetime + ) + ); + + return _services; + } +} + +/// +/// Selects the source type for a named adapter chain. +/// +public sealed class NamedAdapterChainSource( + AdapterChainFactoryBuilder factory, + string name +) + where TTarget : class +{ + /// + /// Specifies the source type for the chain. The instance is resolved from the container + /// when is called. + /// + public NamedAdapterChainBuilder From() + where TSource : class + { + return new NamedAdapterChainBuilder(factory, name, typeof(TSource), []); + } +} + +/// +/// Collects the steps of a named adapter chain. +/// +public sealed class NamedAdapterChainBuilder + where TSource : class + where TTarget : class +{ + private readonly AdapterChainFactoryBuilder _factory; + private readonly string _name; + private readonly Type _originSourceType; + private readonly List _steps; + + internal NamedAdapterChainBuilder( + AdapterChainFactoryBuilder factory, + string name, + Type originSourceType, + List steps + ) + { + _factory = factory; + _name = name; + _originSourceType = originSourceType; + _steps = steps; + } + + /// + /// Adds an intermediate transformation step to the chain. + /// + public NamedAdapterChainBuilder Then() + where TTarget1 : class + where TAdapter : class, IAdapter + { + _steps.Add( + new AdapterChainStep + { + SourceType = typeof(TSource), + TargetType = typeof(TTarget1), + AdapterType = typeof(TAdapter), + IsFinalStep = false, + } + ); + _factory.Services.AddScoped(); + return new NamedAdapterChainBuilder( + _factory, + _name, + _originSourceType, + _steps + ); + } + + /// + /// Adds the final transformation step (to ) and registers + /// the named chain with the factory. + /// + public AdapterChainFactoryBuilder Finally() + where TAdapter : class, IAdapter + { + _steps.Add( + new AdapterChainStep + { + SourceType = typeof(TSource), + TargetType = typeof(TTarget), + AdapterType = typeof(TAdapter), + IsFinalStep = true, + } + ); + _factory.Services.AddScoped(); + + var steps = _steps; + var originType = _originSourceType; + _factory.AddChainProducer( + _name, + provider => + { + var source = provider.GetRequiredService(originType); + return new AdapterChain(provider, steps).ExecuteCore(source); + } + ); + + return _factory; + } +} diff --git a/src/Builders/AsyncAdapterChainBuilder.cs b/src/Builders/AsyncAdapterChainBuilder.cs new file mode 100644 index 0000000..3bd7bb7 --- /dev/null +++ b/src/Builders/AsyncAdapterChainBuilder.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay.Core.Implementations; +using Relay.Core.Interfaces; + +namespace Relay.Builders; + +/// +/// Builder for configuring asynchronous adapter chains. +/// +public sealed class AsyncAdapterChainBuilder : IAsyncAdapterChainBuilder +{ + private readonly IServiceCollection _services; + + internal AsyncAdapterChainBuilder(IServiceCollection services) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + } + + public IAsyncAdapterChainFromBuilder From() + where TSource : class + { + return new AsyncAdapterChainFromBuilder(_services); + } +} + +internal sealed class AsyncAdapterChainFromBuilder + : IAsyncAdapterChainFromBuilder + where TSource : class +{ + private readonly IServiceCollection _services; + private readonly List _steps = []; + + internal AsyncAdapterChainFromBuilder(IServiceCollection services) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + } + + public IAsyncAdapterChainThenBuilder Then() + where TTarget : class + where TAdapter : class, IAsyncAdapter + { + _steps.Add( + new AdapterChainStep + { + SourceType = typeof(TSource), + TargetType = typeof(TTarget), + AdapterType = typeof(TAdapter), + IsFinalStep = false, + } + ); + _services.AddScoped(); + return new AsyncAdapterChainThenBuilder(_services, _steps); + } + + public IAsyncAdapterChainFinalBuilder Finally() + where TAdapter : class, IAsyncAdapter + { + _steps.Add( + new AdapterChainStep + { + SourceType = typeof(TSource), + TargetType = typeof(TResult), + AdapterType = typeof(TAdapter), + IsFinalStep = true, + } + ); + _services.AddScoped(); + return new AsyncAdapterChainFinalBuilder(_services, _steps); + } +} + +internal sealed class AsyncAdapterChainThenBuilder + : IAsyncAdapterChainThenBuilder + where TSource : class +{ + private readonly IServiceCollection _services; + private readonly List _steps; + + internal AsyncAdapterChainThenBuilder(IServiceCollection services, List steps) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _steps = steps ?? throw new ArgumentNullException(nameof(steps)); + } + + public IAsyncAdapterChainThenBuilder Then() + where TTarget : class + where TAdapter : class, IAsyncAdapter + { + _steps.Add( + new AdapterChainStep + { + SourceType = typeof(TSource), + TargetType = typeof(TTarget), + AdapterType = typeof(TAdapter), + IsFinalStep = false, + } + ); + _services.AddScoped(); + return new AsyncAdapterChainThenBuilder(_services, _steps); + } + + public IAsyncAdapterChainFinalBuilder Finally() + where TAdapter : class, IAsyncAdapter + { + _steps.Add( + new AdapterChainStep + { + SourceType = typeof(TSource), + TargetType = typeof(TResult), + AdapterType = typeof(TAdapter), + IsFinalStep = true, + } + ); + _services.AddScoped(); + return new AsyncAdapterChainFinalBuilder(_services, _steps); + } +} + +internal sealed class AsyncAdapterChainFinalBuilder + : IAsyncAdapterChainFinalBuilder +{ + private readonly IServiceCollection _services; + private readonly List _steps; + + internal AsyncAdapterChainFinalBuilder(IServiceCollection services, List steps) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _steps = steps ?? throw new ArgumentNullException(nameof(steps)); + } + + public void Build() + { + _services.AddScoped>(provider => + new AsyncAdapterChain(provider, _steps) + ); + } +} diff --git a/src/Builders/ConditionalRelayBuilder.cs b/src/Builders/ConditionalRelayBuilder.cs index 9f48e92..b92d9c3 100644 --- a/src/Builders/ConditionalRelayBuilder.cs +++ b/src/Builders/ConditionalRelayBuilder.cs @@ -57,8 +57,12 @@ public IServiceCollection Build() typeof(TInterface), provider => { + // Prefer an explicit context flowed in via the resolver, then the registered + // scoped context, then a freshly built default. var context = - provider.GetService() ?? new DefaultRelayContext(provider); + provider.GetService()?.Current + ?? provider.GetService() + ?? new DefaultRelayContext(provider); var registration = _registrations.FirstOrDefault(r => r.Condition(context)); if (registration is null) diff --git a/src/Builders/RelayFactoryBuilder.cs b/src/Builders/RelayFactoryBuilder.cs index d3d01b7..ec28015 100644 --- a/src/Builders/RelayFactoryBuilder.cs +++ b/src/Builders/RelayFactoryBuilder.cs @@ -14,6 +14,8 @@ public sealed class RelayFactoryBuilder(IServiceCollection services) private string? _defaultKey; + private Func? _contextKeySelector; + private ServiceLifetime _lifetime = ServiceLifetime.Scoped; public RelayFactoryBuilder RegisterRelay( @@ -45,6 +47,27 @@ public RelayFactoryBuilder RegisterRelay(string key return this; } + /// + /// Register a relay implementation using native .NET keyed dependency injection. + /// The implementation is registered as a keyed and can + /// be resolved either through the factory or directly via + /// [FromKeyedServices(key)] / GetRequiredKeyedService<TInterface>(key). + /// + public RelayFactoryBuilder RegisterKeyedRelay(string key) + where TImplementation : class, TInterface + { + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } + + _services.Add( + new ServiceDescriptor(typeof(TInterface), key, typeof(TImplementation), _lifetime) + ); + _factories[key] = provider => provider.GetRequiredKeyedService(key); + return this; + } + public RelayFactoryBuilder SetDefaultRelay(string key) { if (string.IsNullOrEmpty(key)) @@ -62,12 +85,29 @@ public RelayFactoryBuilder WithLifetime(ServiceLifetime lifetime) return this; } + /// + /// Select which registered key to use based on the when + /// resolving via . + /// + public RelayFactoryBuilder SelectKeyByContext(Func selector) + { + ArgumentNullException.ThrowIfNull(selector); + _contextKeySelector = selector; + return this; + } + public IServiceCollection Build() { _services.Add( new ServiceDescriptor( typeof(IRelayFactory), - provider => new RelayFactory(_factories, provider, _defaultKey), + provider => + new RelayFactory( + _factories, + provider, + _defaultKey, + _contextKeySelector + ), _lifetime ) ); diff --git a/src/Core/Implementations/AdapterChain.cs b/src/Core/Implementations/AdapterChain.cs index a16b70b..7186008 100644 --- a/src/Core/Implementations/AdapterChain.cs +++ b/src/Core/Implementations/AdapterChain.cs @@ -1,6 +1,8 @@ +using System.Collections.Concurrent; using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Relay.Core.Interfaces; +using Relay.Diagnostics; namespace Relay.Core.Implementations; @@ -28,45 +30,77 @@ List steps private readonly List _steps = steps ?? throw new ArgumentNullException(nameof(steps)); + // Cache the resolved Adapt MethodInfo per adapter type to avoid reflection on every execution. + private static readonly ConcurrentDictionary AdaptMethodCache = new(); + public TResult Execute(TSource? source) { ArgumentNullException.ThrowIfNull(source); + // Verify the first step matches the statically known source type. + var firstStep = _steps.Count > 0 ? _steps[0] : null; + if (firstStep is not null && firstStep.SourceType != typeof(TSource)) + { + throw new InvalidOperationException( + $"Chain expects source type {firstStep.SourceType.Name} but received {typeof(TSource).Name}" + ); + } + + return ExecuteCore(source); + } + + /// + /// Executes the chain against a runtime-typed source. Used when the source type is only + /// known at runtime (e.g. resolved from the container by a named chain factory). + /// + internal TResult ExecuteCore(object source) + { + ArgumentNullException.ThrowIfNull(source); + if (_steps.Count == 0) { throw new InvalidOperationException("Adapter chain has no steps configured"); } object current = source; - var sourceType = typeof(TSource); - // Verify first step matches source type + // Verify the first step accepts the provided source instance. var firstStep = _steps[0]; - if (firstStep.SourceType != sourceType) + if (!firstStep.SourceType.IsInstanceOfType(source)) { throw new InvalidOperationException( - $"Chain expects source type {firstStep.SourceType.Name} but received {sourceType.Name}" + $"Chain expects source type {firstStep.SourceType.Name} but received {source.GetType().Name}" ); } + using var chainActivity = RelayDiagnostics.ActivitySource.StartActivity("AdapterChain.Execute"); + chainActivity?.SetTag("relay.chain.result_type", typeof(TResult).Name); + chainActivity?.SetTag("relay.chain.source_type", firstStep.SourceType.Name); + chainActivity?.SetTag("relay.chain.steps", _steps.Count); + // Execute each step in the chain foreach (var step in _steps) { - var adapter = _serviceProvider.GetRequiredService(step.AdapterType); + using var stepActivity = RelayDiagnostics.ActivitySource.StartActivity("AdapterChain.Step"); + stepActivity?.SetTag("relay.adapter", step.AdapterType.Name); + stepActivity?.SetTag("relay.adapter.target", step.TargetType.Name); - // Use reflection to call the Adapt method - var adaptMethod = step - .AdapterType.GetInterfaces() - .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAdapter<,>)) - .SelectMany(i => i.GetMethods()) - .FirstOrDefault(m => m.Name == "Adapt"); + var adapter = _serviceProvider.GetRequiredService(step.AdapterType); - if (adaptMethod is null) - { - throw new InvalidOperationException( - $"Adapter {step.AdapterType.Name} does not implement IAdapter<,> properly" - ); - } + // Resolve the Adapt method once per adapter type, then reuse the cached MethodInfo. + var adaptMethod = AdaptMethodCache.GetOrAdd( + step.AdapterType, + static type => + type.GetInterfaces() + .Where(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAdapter<,>) + ) + .SelectMany(i => i.GetMethods()) + .FirstOrDefault(m => m.Name == "Adapt") + ?? throw new InvalidOperationException( + $"Adapter {type.Name} does not implement IAdapter<,> properly" + ) + ); try { diff --git a/src/Core/Implementations/AdapterChainFactory.cs b/src/Core/Implementations/AdapterChainFactory.cs index ea43aca..2d04101 100644 --- a/src/Core/Implementations/AdapterChainFactory.cs +++ b/src/Core/Implementations/AdapterChainFactory.cs @@ -1,14 +1,19 @@ -using Microsoft.Extensions.DependencyInjection; using Relay.Core.Interfaces; namespace Relay.Core.Implementations; -public sealed class AdapterChainFactory(List chainNames, IServiceProvider provider) - : IAdapterChainFactory +/// +/// Factory that resolves and executes one of several named adapter chains, all producing +/// the same . +/// +public sealed class AdapterChainFactory( + IReadOnlyDictionary> chains, + IServiceProvider provider +) : IAdapterChainFactory where TTarget : class { - private readonly List _chainNames = - chainNames ?? throw new ArgumentNullException(nameof(chainNames)); + private readonly IReadOnlyDictionary> _chains = + chains ?? throw new ArgumentNullException(nameof(chains)); private readonly IServiceProvider _provider = provider ?? throw new ArgumentNullException(nameof(provider)); @@ -20,14 +25,13 @@ public TTarget CreateFromChain(string chainName) throw new ArgumentException("Chain name cannot be null or empty", nameof(chainName)); } - if (!_chainNames.Contains(chainName)) + if (!_chains.TryGetValue(chainName, out var chain)) { throw new ArgumentException($"Chain '{chainName}' not found", nameof(chainName)); } - // This would need more sophisticated resolution based on the actual chain configuration - return _provider.GetRequiredService(); + return chain(_provider); } - public IEnumerable GetAvailableChains() => _chainNames; + public IEnumerable GetAvailableChains() => _chains.Keys; } diff --git a/src/Core/Implementations/AsyncAdapterChain.cs b/src/Core/Implementations/AsyncAdapterChain.cs new file mode 100644 index 0000000..ba03284 --- /dev/null +++ b/src/Core/Implementations/AsyncAdapterChain.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Relay.Core.Interfaces; +using Relay.Diagnostics; + +namespace Relay.Core.Implementations; + +/// +/// Implementation of an asynchronous adapter chain that executes a sequence of +/// transformations. +/// +public sealed class AsyncAdapterChain( + IServiceProvider serviceProvider, + List steps +) : IAsyncAdapterChain +{ + private readonly IServiceProvider _serviceProvider = + serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly List _steps = + steps ?? throw new ArgumentNullException(nameof(steps)); + + // Cache the resolved AdaptAsync MethodInfo and its Task.Result getter per adapter type. + private static readonly ConcurrentDictionary AdaptAsyncCache = + new(); + + public async Task ExecuteAsync( + TSource source, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(source); + + if (_steps.Count == 0) + { + throw new InvalidOperationException("Adapter chain has no steps configured"); + } + + object current = source; + var sourceType = typeof(TSource); + + var firstStep = _steps[0]; + if (firstStep.SourceType != sourceType) + { + throw new InvalidOperationException( + $"Chain expects source type {firstStep.SourceType.Name} but received {sourceType.Name}" + ); + } + + using var chainActivity = RelayDiagnostics.ActivitySource.StartActivity("AsyncAdapterChain.Execute"); + chainActivity?.SetTag("relay.chain.result_type", typeof(TResult).Name); + chainActivity?.SetTag("relay.chain.source_type", sourceType.Name); + chainActivity?.SetTag("relay.chain.steps", _steps.Count); + + foreach (var step in _steps) + { + using var stepActivity = RelayDiagnostics.ActivitySource.StartActivity("AsyncAdapterChain.Step"); + stepActivity?.SetTag("relay.adapter", step.AdapterType.Name); + stepActivity?.SetTag("relay.adapter.target", step.TargetType.Name); + + var adapter = _serviceProvider.GetRequiredService(step.AdapterType); + + var (method, resultProperty) = AdaptAsyncCache.GetOrAdd( + step.AdapterType, + static type => + { + var m = + type.GetInterfaces() + .Where(i => + i.IsGenericType + && i.GetGenericTypeDefinition() == typeof(IAsyncAdapter<,>) + ) + .SelectMany(i => i.GetMethods()) + .FirstOrDefault(x => x.Name == "AdaptAsync") + ?? throw new InvalidOperationException( + $"Adapter {type.Name} does not implement IAsyncAdapter<,> properly" + ); + var resultProp = + m.ReturnType.GetProperty("Result") + ?? throw new InvalidOperationException( + $"Adapter {type.Name}.AdaptAsync must return Task" + ); + return (m, resultProp); + } + ); + + Task task; + try + { + task = (Task)method.Invoke(adapter, [current, cancellationToken])!; + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + throw ex.InnerException; + } + + await task.ConfigureAwait(false); + current = resultProperty.GetValue(task)!; + } + + if (current is not TResult result) + { + throw new InvalidOperationException( + $"Final result type {current?.GetType().Name} cannot be cast to expected type {typeof(TResult).Name}" + ); + } + + return result; + } +} diff --git a/src/Core/Implementations/MultiRelay.cs b/src/Core/Implementations/MultiRelay.cs index 1553c6b..97de897 100644 --- a/src/Core/Implementations/MultiRelay.cs +++ b/src/Core/Implementations/MultiRelay.cs @@ -1,5 +1,6 @@ using Relay.Core.Enums; using Relay.Core.Interfaces; +using Relay.Diagnostics; namespace Relay.Core.Implementations; @@ -9,7 +10,6 @@ public sealed class MultiRelay : IMultiRelay private readonly List _relays; private readonly RelayStrategy _strategy; private int _roundRobinIndex; - private readonly object _lockObject = new(); public MultiRelay(IEnumerable relays, RelayStrategy strategy) { @@ -26,6 +26,10 @@ Func> operation { ArgumentNullException.ThrowIfNull(operation); + using var activity = RelayDiagnostics.ActivitySource.StartActivity("MultiRelay.RelayToAllWithResults"); + activity?.SetTag("relay.strategy", _strategy.ToString()); + activity?.SetTag("relay.count", _relays.Count); + return _strategy switch { RelayStrategy.Broadcast => await ExecuteBroadcast(operation), @@ -41,6 +45,10 @@ public async Task RelayToAll(Func operation) { ArgumentNullException.ThrowIfNull(operation); + using var activity = RelayDiagnostics.ActivitySource.StartActivity("MultiRelay.RelayToAll"); + activity?.SetTag("relay.strategy", _strategy.ToString()); + activity?.SetTag("relay.count", _relays.Count); + switch (_strategy) { case RelayStrategy.Broadcast: @@ -71,12 +79,10 @@ public Task GetNextRelay() throw new InvalidOperationException("No relays available"); } - lock (_lockObject) - { - var relay = _relays[_roundRobinIndex % _relays.Count]; - _roundRobinIndex++; - return Task.FromResult(relay); - } + // Lock-free round-robin: atomically grab and advance the index. + var index = Interlocked.Increment(ref _roundRobinIndex) - 1; + var relay = _relays[(int)((uint)index % (uint)_relays.Count)]; + return Task.FromResult(relay); } private async Task> ExecuteBroadcast( diff --git a/src/Core/Implementations/RelayContextAccessor.cs b/src/Core/Implementations/RelayContextAccessor.cs new file mode 100644 index 0000000..7949679 --- /dev/null +++ b/src/Core/Implementations/RelayContextAccessor.cs @@ -0,0 +1,12 @@ +using Relay.Core.Interfaces; + +namespace Relay.Core.Implementations; + +/// +/// Scoped, mutable holder for the ambient . +/// +public sealed class RelayContextAccessor : IRelayContextAccessor +{ + /// + public IRelayContext? Current { get; set; } +} diff --git a/src/Core/Implementations/RelayFactory.cs b/src/Core/Implementations/RelayFactory.cs index 12b45b6..792b735 100644 --- a/src/Core/Implementations/RelayFactory.cs +++ b/src/Core/Implementations/RelayFactory.cs @@ -5,7 +5,8 @@ namespace Relay.Core.Implementations; public sealed class RelayFactory( IReadOnlyDictionary> factories, IServiceProvider serviceProvider, - string? defaultKey + string? defaultKey, + Func? contextKeySelector = null ) : IRelayFactory where TInterface : class { @@ -34,7 +35,12 @@ public TInterface CreateRelay(IRelayContext context) { ArgumentNullException.ThrowIfNull(context); - // Default implementation - can be overridden for context-based selection + // Use the configured context-based selector to pick a key; fall back to the default relay. + if (contextKeySelector is not null) + { + return CreateRelay(contextKeySelector(context)); + } + return GetDefaultRelay(); } diff --git a/src/Core/Implementations/RelayResolver.cs b/src/Core/Implementations/RelayResolver.cs index 0ae6c22..802e890 100644 --- a/src/Core/Implementations/RelayResolver.cs +++ b/src/Core/Implementations/RelayResolver.cs @@ -12,6 +12,24 @@ public TInterface Resolve(IRelayContext? context = null) where TInterface : class { context ??= new DefaultRelayContext(_serviceProvider); - return _serviceProvider.GetRequiredService(); + + // Flow the context into the ambient accessor so conditional relays and factories + // resolved below can route based on it, then restore the previous value. + var accessor = _serviceProvider.GetService(); + if (accessor is null) + { + return _serviceProvider.GetRequiredService(); + } + + var previous = accessor.Current; + accessor.Current = context; + try + { + return _serviceProvider.GetRequiredService(); + } + finally + { + accessor.Current = previous; + } } } diff --git a/src/Core/Interfaces/IAsyncAdapterChain.cs b/src/Core/Interfaces/IAsyncAdapterChain.cs new file mode 100644 index 0000000..52f5032 --- /dev/null +++ b/src/Core/Interfaces/IAsyncAdapterChain.cs @@ -0,0 +1,90 @@ +namespace Relay.Core.Interfaces; + +/// +/// Represents an adapter that asynchronously transforms from one type to another. +/// Use for transformation steps that perform I/O (HTTP, database, file access). +/// +public interface IAsyncAdapter +{ + /// + /// Asynchronously adapts/transforms the source object to the target type. + /// + Task AdaptAsync(TSource source, CancellationToken cancellationToken = default); +} + +/// +/// Represents an asynchronous adapter chain that executes a sequence of transformations. +/// +public interface IAsyncAdapterChain +{ + /// + /// Asynchronously executes the adapter chain starting from the source data. + /// + Task ExecuteAsync( + TSource source, + CancellationToken cancellationToken = default + ); +} + +/// +/// Builder for configuring asynchronous adapter chains. +/// +public interface IAsyncAdapterChainBuilder +{ + /// + /// Specifies the source type for the adapter chain. + /// + IAsyncAdapterChainFromBuilder From() + where TSource : class; +} + +/// +/// Builder for configuring asynchronous adapter chains after specifying the source. +/// +public interface IAsyncAdapterChainFromBuilder + where TSource : class +{ + /// + /// Adds an intermediate asynchronous transformation step to the chain. + /// + IAsyncAdapterChainThenBuilder Then() + where TTarget : class + where TAdapter : class, IAsyncAdapter; + + /// + /// Adds the final asynchronous transformation step to the chain. + /// + IAsyncAdapterChainFinalBuilder Finally() + where TAdapter : class, IAsyncAdapter; +} + +/// +/// Builder for configuring subsequent steps in the asynchronous adapter chain. +/// +public interface IAsyncAdapterChainThenBuilder + where TSource : class +{ + /// + /// Adds another intermediate asynchronous transformation step to the chain. + /// + IAsyncAdapterChainThenBuilder Then() + where TTarget : class + where TAdapter : class, IAsyncAdapter; + + /// + /// Adds the final asynchronous transformation step to the chain. + /// + IAsyncAdapterChainFinalBuilder Finally() + where TAdapter : class, IAsyncAdapter; +} + +/// +/// Final builder for completing the asynchronous adapter chain configuration. +/// +public interface IAsyncAdapterChainFinalBuilder +{ + /// + /// Builds the asynchronous adapter chain and registers it with the service collection. + /// + void Build(); +} diff --git a/src/Core/Interfaces/IRelayContextAccessor.cs b/src/Core/Interfaces/IRelayContextAccessor.cs new file mode 100644 index 0000000..c4060a9 --- /dev/null +++ b/src/Core/Interfaces/IRelayContextAccessor.cs @@ -0,0 +1,14 @@ +namespace Relay.Core.Interfaces; + +/// +/// Provides ambient access to the for the current resolution. +/// Set by when a caller passes an explicit context so that +/// conditional relays and factories can route based on it. +/// +public interface IRelayContextAccessor +{ + /// + /// The context for the current resolution, or null when none has been set. + /// + IRelayContext? Current { get; set; } +} diff --git a/src/Diagnostics/RelayDiagnostics.cs b/src/Diagnostics/RelayDiagnostics.cs new file mode 100644 index 0000000..a090e31 --- /dev/null +++ b/src/Diagnostics/RelayDiagnostics.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using System.Reflection; + +namespace Relay.Diagnostics; + +/// +/// Central for Relay. Subscribe to the +/// "Relay" source with an or OpenTelemetry to observe +/// adapter chain steps and multi-relay strategy execution. +/// +/// +/// Activities are only created when a listener is registered, so the overhead is +/// effectively zero when nobody is observing. +/// +public static class RelayDiagnostics +{ + /// + /// The name of the emitted by Relay. + /// Use this string to enable tracing (e.g. builder.AddSource(RelayDiagnostics.SourceName)). + /// + public const string SourceName = "Relay"; + + private static readonly string Version = + typeof(RelayDiagnostics).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(RelayDiagnostics).Assembly.GetName().Version?.ToString() + ?? "unknown"; + + /// + /// The shared instance used by Relay components. + /// + public static ActivitySource ActivitySource { get; } = new(SourceName, Version); +} diff --git a/src/Relay.csproj b/src/Relay.csproj index 32d2ec3..81ac40c 100644 --- a/src/Relay.csproj +++ b/src/Relay.csproj @@ -1,9 +1,9 @@ - net9.0; + net10.0; Relay Relay - Adaptive Dependency Injection - 1.0.3 + 1.1.0 Taras Kovalenko Copyright Taras Kovalenko Relay diff --git a/src/ServiceCollectionExtensions.cs b/src/ServiceCollectionExtensions.cs index 8b610f8..4c7619a 100644 --- a/src/ServiceCollectionExtensions.cs +++ b/src/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ ο»Ώusing Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Relay.Builders; using Relay.Core.Implementations; using Relay.Core.Interfaces; @@ -40,6 +41,32 @@ this IServiceCollection services return new RelayRegistrationBuilder(services, typeof(TImplementation)); } + /// + /// Register a relay against a service key using native .NET keyed dependency injection. + /// Resolve it with [FromKeyedServices(key)] or + /// GetRequiredKeyedService<TInterface>(key). + /// + public static IServiceCollection AddKeyedRelay( + this IServiceCollection services, + object? serviceKey, + ServiceLifetime lifetime = ServiceLifetime.Scoped + ) + where TInterface : class + where TImplementation : class, TInterface + { + ArgumentNullException.ThrowIfNull(services); + + services.Add( + new ServiceDescriptor( + typeof(TInterface), + serviceKey, + typeof(TImplementation), + lifetime + ) + ); + return services; + } + /// /// Register conditional relay that routes based on context /// @@ -96,6 +123,7 @@ public static IServiceCollection AddRelayServices(this IServiceCollection servic services.AddScoped(); services.AddScoped(); + services.TryAddScoped(); return services; } @@ -110,6 +138,29 @@ this IServiceCollection services return new AdapterChainBuilder(services); } + /// + /// Register an asynchronous adapter chain for transformation pipelines that perform I/O. + /// + public static AsyncAdapterChainBuilder AddAsyncAdapterChain( + this IServiceCollection services + ) + { + ArgumentNullException.ThrowIfNull(services); + return new AsyncAdapterChainBuilder(services); + } + + /// + /// Register a factory that exposes several named adapter chains, all producing the same result. + /// + public static AdapterChainFactoryBuilder AddAdapterChainFactory( + this IServiceCollection services + ) + where TTarget : class + { + ArgumentNullException.ThrowIfNull(services); + return new AdapterChainFactoryBuilder(services); + } + /// /// Register a strongly-typed adapter chain with known source and target types /// diff --git a/tests/Relay.Tests/Features/AdapterChainFactoryTests.cs b/tests/Relay.Tests/Features/AdapterChainFactoryTests.cs new file mode 100644 index 0000000..f95aa4d --- /dev/null +++ b/tests/Relay.Tests/Features/AdapterChainFactoryTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay.Core.Interfaces; +using Shouldly; + +namespace Relay.Tests.Features; + +public class AdapterChainFactoryTests +{ + public record Raw(string Value); + + public record Intermediate(string Value); + + public sealed class Settings + { + public required string Value { get; init; } + } + + public class RawToIntermediate : IAdapter + { + public Intermediate Adapt(Raw source) => new($"{source.Value}->mid"); + } + + public class IntermediateToSettings : IAdapter + { + public Settings Adapt(Intermediate source) => new() { Value = $"{source.Value}->settings" }; + } + + public class RawToSettings : IAdapter + { + public Settings Adapt(Raw source) => new() { Value = $"{source.Value}->direct" }; + } + + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(new Raw("seed")); + services + .AddAdapterChainFactory() + .AddChain("full") + .From() + .Then() + .Finally() + .AddChain("direct") + .From() + .Finally() + .AddChain("mock", _ => new Settings { Value = "mock" }) + .Build(); + + return services.BuildServiceProvider(); + } + + [Fact] + public void GetAvailableChains_ReturnsAllRegisteredNames() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + factory.GetAvailableChains().ShouldBe(["full", "direct", "mock"], ignoreOrder: true); + } + + [Fact] + public void CreateFromChain_MultiStep_ExecutesEntireChain() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + factory.CreateFromChain("full").Value.ShouldBe("seed->mid->settings"); + } + + [Fact] + public void CreateFromChain_DirectChain_ExecutesSingleStep() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + factory.CreateFromChain("direct").Value.ShouldBe("seed->direct"); + } + + [Fact] + public void CreateFromChain_ProducerDelegate_Works() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + factory.CreateFromChain("mock").Value.ShouldBe("mock"); + } + + [Fact] + public void CreateFromChain_UnknownName_Throws() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + Should.Throw(() => factory.CreateFromChain("nope")); + } + + [Fact] + public void CreateFromChain_EmptyName_Throws() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + Should.Throw(() => factory.CreateFromChain("")); + } +} diff --git a/tests/Relay.Tests/Features/NewFeaturesTests.cs b/tests/Relay.Tests/Features/NewFeaturesTests.cs new file mode 100644 index 0000000..cfff6c4 --- /dev/null +++ b/tests/Relay.Tests/Features/NewFeaturesTests.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Relay.Core.Implementations; +using Relay.Core.Interfaces; +using Relay.Diagnostics; +using Shouldly; + +namespace Relay.Tests.Features; + +public class NewFeaturesTests +{ + // ---- Async adapter chain test types ---- + public record Src(int N); + + public record Mid(int N); + + public record Dst(string S); + + public class IncrementAdapter : IAsyncAdapter + { + public async Task AdaptAsync(Src source, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); + return new Mid(source.N + 1); + } + } + + public class FormatAdapter : IAsyncAdapter + { + public async Task AdaptAsync(Mid source, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); + return new Dst($"v{source.N}"); + } + } + + [Fact] + public async Task AsyncAdapterChain_ExecutesAllSteps() + { + var services = new ServiceCollection(); + services + .AddAsyncAdapterChain() + .From() + .Then() + .Finally() + .Build(); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var chain = scope.ServiceProvider.GetRequiredService>(); + + var result = await chain.ExecuteAsync(new Src(1)); + + result.S.ShouldBe("v2"); + } + + [Fact] + public async Task AsyncAdapterChain_PropagatesAdapterException() + { + var services = new ServiceCollection(); + services + .AddAsyncAdapterChain() + .From() + .Then() + .Finally() + .Build(); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var chain = scope.ServiceProvider.GetRequiredService>(); + + await Should.ThrowAsync(() => chain.ExecuteAsync(new Src(1))); + } + + public class ThrowingAsyncAdapter : IAsyncAdapter + { + public Task AdaptAsync(Src source, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("boom"); + } + + // ---- Context-aware resolution ---- + [Fact] + public void Resolver_FlowsContext_ToConditionalRelay() + { + var services = new ServiceCollection(); + services.AddRelayServices(); + services + .AddConditionalRelay() + .When(ctx => + ctx.Properties.TryGetValue("variant", out var v) && (string)v == "A" + ) + .RelayTo() + .Otherwise() + .Build(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var resolver = scope.ServiceProvider.GetRequiredService(); + + var ctxA = new DefaultRelayContext(scope.ServiceProvider); + ctxA.Properties["variant"] = "A"; + resolver.Resolve(ctxA).ShouldBeOfType(); + } + + [Fact] + public void Resolver_WithoutMatchingContext_UsesOtherwise() + { + var services = new ServiceCollection(); + services.AddRelayServices(); + services + .AddConditionalRelay() + .When(ctx => ctx.Properties.ContainsKey("variant")) + .RelayTo() + .Otherwise() + .Build(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var resolver = scope.ServiceProvider.GetRequiredService(); + + var ctx = new DefaultRelayContext(scope.ServiceProvider); + resolver.Resolve(ctx).ShouldBeOfType(); + } + + // ---- Native keyed services ---- + [Fact] + public void AddKeyedRelay_ResolvesViaKeyedService() + { + var services = new ServiceCollection(); + services.AddKeyedRelay("a"); + services.AddKeyedRelay("b"); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredKeyedService("a").ShouldBeOfType(); + provider.GetRequiredKeyedService("b").ShouldBeOfType(); + } + + [Fact] + public void RelayFactory_KeyedAndContextSelector_Work() + { + var services = new ServiceCollection(); + var builder = services.AddRelayFactory(cfg => + cfg.RegisterKeyedRelay("a") + .RegisterKeyedRelay("b") + .SelectKeyByContext(ctx => (string)ctx.Properties["key"]) + .SetDefaultRelay("a") + ); + builder.Build(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + + factory.CreateRelay("a").ShouldBeOfType(); + factory.GetDefaultRelay().ShouldBeOfType(); + + var ctx = new DefaultRelayContext(scope.ServiceProvider); + ctx.Properties["key"] = "b"; + factory.CreateRelay(ctx).ShouldBeOfType(); + } + + // ---- Diagnostics ---- + [Fact] + public async Task AdapterChain_EmitsActivity() + { + var activities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == RelayDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllData, + ActivityStarted = a => activities.Add(a.OperationName), + }; + ActivitySource.AddActivityListener(listener); + + var services = new ServiceCollection(); + services + .AddAsyncAdapterChain() + .From() + .Then() + .Finally() + .Build(); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var chain = scope.ServiceProvider.GetRequiredService>(); + await chain.ExecuteAsync(new Src(1)); + + activities.ShouldContain("AsyncAdapterChain.Execute"); + activities.ShouldContain("AsyncAdapterChain.Step"); + } +} diff --git a/tests/Relay.Tests/Relay.Tests.csproj b/tests/Relay.Tests/Relay.Tests.csproj index 03d9c37..9802503 100644 --- a/tests/Relay.Tests/Relay.Tests.csproj +++ b/tests/Relay.Tests/Relay.Tests.csproj @@ -1,6 +1,6 @@ - net9.0; + net10.0; enable enable false