diff --git a/AGENTS.md b/AGENTS.md index 9835d8d..d7019c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,22 @@ ## Project overview -A small .NET 10 NuGet library (`BauerApps.Dataverse.Extensions.DependencyInjection`) that provides one-line DI registration for Microsoft Dataverse `ServiceClient`. The key design decision: `ServiceClient` is registered as a **singleton** (shares connection pool & metadata cache), while `IOrganizationServiceAsync2` is registered as **scoped** via `Clone()` (thread-safe per-request usage). Do not change these lifetimes. +A small .NET 10 NuGet library (`BauerApps.Dataverse.Extensions.DependencyInjection`) that provides one-line DI registration for Microsoft Dataverse `ServiceClient`. The key design decision: `ServiceClient` is a singleton (shared connection + token cache) while `IOrganizationServiceAsync2` is scoped (per-request `Clone()`). ## Scope & non-goals -The library has a single responsibility: **wire up `ServiceClient` and register it for DI** with the correct lifetimes and authentication. Keep it a thin wiring layer built only on the public, supported `ServiceClient` surface. +The library has a single responsibility: **wire up `ServiceClient` and register it for DI** with the correct lifetimes and authentication. Keep it a thin wiring layer built only on the public, supported Dataverse SDK surface. -- **In scope**: DI registration (singleton client + scoped `Clone()`), authentication (Azure.Identity + options pattern), logger wiring, startup validation. -- **Non-goals**: behavioral wrappers/decorators over `IOrganizationService*` (request tagging/correlation, retries, caching, auditing, etc.), constructing credentials from primitive config fields (clientId/secret/etc. — accept a `TokenCredential` instead), and anything that depends on or reimplements `ServiceClient` internals or undocumented behavior. +- **In scope**: DI registration (singleton client + scoped `Clone()`), keyed (multi-environment) client registration, authentication (Azure.Identity + options pattern), logger wiring, startup validation. +- **Non-goals**: behavioral wrappers/decorators over `IOrganizationService*` (request tagging/correlation, retries, caching, auditing, etc.), constructing credentials from primitive config fields (tenantId/clientId/secret strings), FetchXml helpers, entity mapping. -Rationale: maintainability and resilience to changes in the floating `1.*` Dataverse client dependency. Cross-cutting request behavior is the consumer's responsibility. When in doubt, prefer *not* adding the feature. +Rationale: maintainability and resilience to changes in the floating `1.*` Dataverse client dependency. Cross-cutting request behavior is the consumer's responsibility. When in doubt, prefer *not* adding something. -## Architecture (4 source files) +## Architecture -- `ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads (one taking `Action`, one taking `IConfiguration` to bind from a config section) using C# 14 `extension` blocks. Shared wiring/validation live in private static helpers (`AddDataverseClientCore`, `ValidateDataverseClientOptions`). +- `ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads: unkeyed (one taking `Action`, one taking `IConfiguration`) and keyed (same two overloads with a leading `string key` parameter). Uses C# 14 `extension` block style. - `DataverseClientOptions.cs` — Options POCO with `OrganizationUrl` (required), `TokenCredential`, `DeferConnection`. -- `Internal/ServiceClientFactory.cs` — Singleton factory wiring `Azure.Identity` credentials into `ConnectionOptions.AccessTokenProviderFunctionAsync`. Marked `internal`, tested via `InternalsVisibleTo`. +- `Internal/ServiceClientFactory.cs` — Factory with two methods: `Create` (unkeyed, uses `IOptions`) and `CreateKeyed` (uses `IOptionsMonitor.Get(key)`). Marked `internal`, tested via `InternalsVisibleTo`. - Root namespace is `BauerApps.Dataverse.Extensions` (set via `` in csproj, differs from folder name). ## Build & test @@ -27,7 +27,7 @@ dotnet build Dataverse.Extensions.slnx dotnet test Dataverse.Extensions.slnx ``` -Solution uses `.slnx` format (XML-based), not `.sln`. Tests use **TUnit** (not xUnit/NUnit/MSTest) — uses `[Test]` attribute and `await Assert.That(...)` fluent async assertions. The test runner is configured in `global.json` (`"runner": "Microsoft.Testing.Platform"`). +Solution uses `.slnx` format (XML-based), not `.sln`. Tests use **TUnit** (not xUnit/NUnit/MSTest) — uses `[Test]` attribute and `await Assert.That(...)` fluent async assertions. The test runner is TUnit's own; `dotnet test` works via the adapter. ## Conventions @@ -35,23 +35,25 @@ Solution uses `.slnx` format (XML-based), not `.sln`. Tests use **TUnit** (not x - **Internal access**: `InternalsVisibleTo` is configured via `` in csproj, not `AssemblyInfo.cs`. - **Test structure**: Mirrors source layout. `ServiceCollectionExtensionsTests.cs` at root, `Internal/ServiceClientFactoryTests.cs` for internal classes. Tests use Arrange/Act/Assert with comments. - **Test pattern**: All tests set `DeferConnection = true` to avoid real Dataverse connections. Use `FakeTokenCredential` (private nested class) when testing custom credentials. +- **Test naming**: Unkeyed tests use the method name directly (e.g. `AddDataverseClient_RegistersServiceClient`). Keyed tests use `_Keyed_` segment (e.g. `AddDataverseClient_Keyed_RegistersKeyedServiceClient`). Factory tests follow `Create_` and `CreateKeyed_` prefixes. - **C# 14 features**: Uses `extension` blocks (not classic `static` extension methods). Keep this style for new extensions. -- **Dependencies**: `Azure.Identity`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions` (for the `IConfiguration` binding overload), `Microsoft.PowerPlatform.Dataverse.Client`. The Dataverse client uses floating version `1.*`. +- **Dependencies**: `Azure.Identity`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions` (for the `IConfiguration` binding overload), `Microsoft.PowerPlatform.Dataverse.Client`. +- **Documentation**: `README.md` (user-facing) and `AGENTS.md` (agent-facing) **must both be updated** whenever a new feature is added or existing API behaviour changes. README covers usage examples; AGENTS.md covers architecture, conventions, and patterns. ## CI/CD & versioning -- **Versioning**: Automated via [release-please](https://github.com/googleapis/release-please). Version is tracked in `.release-please-manifest.json` (keyed by package directory) and patched into csproj `` via the `` marker comment. +- **Versioning**: Automated via [release-please](https://github.com/googleapis/release-please). Version is tracked in `.release-please-manifest.json` (keyed by package directory) and patched into `` in the csproj by release-please — never edit these manually. - **Commit messages**: Use [Conventional Commits](https://www.conventionalcommits.org/) — `feat:` (minor bump), `fix:` (patch), `feat!:` or `BREAKING CHANGE` footer (major). - **CI** (`.github/workflows/ci.yml`): Builds and tests on every push/PR to `main`. -- **Release** (`.github/workflows/release.yml`): On push to `main`, release-please opens/updates a Release PR. Merging it creates a GitHub release + tag, then publishes to NuGet via [trusted publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) (OIDC via `NuGet/login@v1`, no long-lived API key). +- **Release** (`.github/workflows/release.yml`): On push to `main`, release-please opens/updates a Release PR. Merging it creates a GitHub release + tag, then publishes to NuGet via trusted publishing. ## Release process -Releases are fully automated by **release-please**; never bump the version, edit `CHANGELOG.md`, or tag manually. The version flow is driven entirely by commit messages, so writing correct [Conventional Commits](https://www.conventionalcommits.org/) is what makes the process work. +Releases are fully automated by **release-please**; never bump the version, edit `CHANGELOG.md`, or tag manually. The version flow is driven entirely by commit messages, so writing correct [Conventional Commits](https://www.conventionalcommits.org/) is critical. **Configuration**: -- `release-please-config.json` — `release-type: simple` for the single package `BauerApps.Dataverse.Extensions.DependencyInjection`. `include-component-in-tag: false` (tags are plain `vX.Y.Z`). The csproj is listed as a `generic` extra-file so its `` (marked with ``) is updated on release. Changelog is written to `Dataverse.Extensions.DependencyInjection/CHANGELOG.md`. +- `release-please-config.json` — `release-type: simple` for the single package `BauerApps.Dataverse.Extensions.DependencyInjection`. `include-component-in-tag: false` (tags are plain `vX.Y.Z`). - `.release-please-manifest.json` — the current released version, keyed by package directory. Do not edit by hand; release-please maintains it. **Commit message rules** (these determine the next version bump): @@ -73,13 +75,19 @@ Notes: 1. Open a PR with Conventional Commit message(s) and merge to `main`. 2. release-please opens (or updates) a **Release PR** that bumps the version, updates `CHANGELOG.md`, and updates `.release-please-manifest.json`. 3. Review and merge the Release PR. This creates the GitHub release + `vX.Y.Z` tag. -4. The `publish` job (gated on `release_created`) then builds, tests, packs, and publishes the package to NuGet via trusted publishing (OIDC via `NuGet/login@v1`, `--skip-duplicate`) — no manual `dotnet nuget push` and no stored API key. It also attaches the `.nupkg` to the GitHub release and flips the Release PR label from `autorelease: tagged` to `autorelease: published`. +4. The `publish` job (gated on `release_created`) then builds, tests, packs, and publishes the package to NuGet via trusted publishing (OIDC via `NuGet/login@v1`, `--skip-duplicate`) — no manual steps needed. ## Key patterns When adding new configuration options: 1. Add property to `DataverseClientOptions` -2. Add validation in `ServiceCollectionExtensions.AddDataverseClient()` via `.Validate()` if required -3. Wire it in `ServiceClientFactory.Create()` using the options pattern (`IOptions`) +2. Add validation in `ServiceCollectionExtensions` via `.Validate()` if required +3. Wire it in `ServiceClientFactory` using the appropriate options pattern (`IOptions` for unkeyed, `IOptionsMonitor` for keyed) 4. Test both the registration (service descriptor assertions) and the factory behavior +5. Update `README.md` with usage examples and `AGENTS.md` with any architecture or convention changes +When adding a new keyed registration: +- Use `AddKeyedSingleton` / `AddKeyedScoped` — do NOT reuse the unkeyed core path +- Thread the key through via the keyed service factory delegate `(sp, key) => ...` +- Use `IOptionsMonitor.Get(key)` — never `IOptions` for keyed registrations +- Keep unkeyed and keyed paths fully independent to avoid `Options.DefaultName` collisions diff --git a/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs b/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs index 90fef8c..bcdade5 100644 --- a/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs +++ b/Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs @@ -1,4 +1,5 @@ using Azure.Core; +using BauerApps.Dataverse.Extensions.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.PowerPlatform.Dataverse.Client; @@ -80,6 +81,88 @@ public async Task Create_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue await Assert.That(client).IsNotNull(); } + [Test] + public async Task CreateKeyed_UsesKeyedOptions() + { + // Arrange + const string key = "source"; + var services = new ServiceCollection(); + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://keyed-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateKeyed(provider, key); + + // Assert + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task CreateKeyed_UsesDefaultAzureCredentialWhenTokenCredentialIsNull() + { + // Arrange + const string key = "source"; + var services = new ServiceCollection(); + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateKeyed(provider, key); + + // Assert — client is created without throwing (deferred connection) + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task CreateKeyed_UsesCustomTokenCredential() + { + // Arrange + const string key = "source"; + var customCredential = new FakeTokenCredential(); + var services = new ServiceCollection(); + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.TokenCredential = customCredential; + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateKeyed(provider, key); + + // Assert + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task CreateKeyed_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue() + { + // Arrange + const string key = "source"; + var services = new ServiceCollection(); + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://invalid-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + var provider = services.BuildServiceProvider(); + + // Act + var client = ServiceClientFactory.CreateKeyed(provider, key); + + // Assert — deferred connection should not throw + await Assert.That(client).IsNotNull(); + } + /// /// Minimal fake TokenCredential for testing. /// @@ -92,5 +175,3 @@ public override ValueTask GetTokenAsync(TokenRequestContext request => new(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1))); } } - - diff --git a/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs b/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs index 05394aa..67262c5 100644 --- a/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs +++ b/Dataverse.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTests.cs @@ -119,6 +119,201 @@ await Assert.That(options.OrganizationUrl) await Assert.That(options.TokenCredential).IsNull(); } + [Test] + public async Task AddDataverseClient_Keyed_RegistersKeyedServiceClient() + { + // Arrange + const string key = "source"; + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + + // Assert + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == key + && x.Lifetime == ServiceLifetime.Singleton); + } + + [Test] + public async Task AddDataverseClient_Keyed_RegistersKeyedScopedIOrganizationServiceAsync2() + { + // Arrange + const string key = "source"; + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + + // Assert + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(IOrganizationServiceAsync2) + && x.ServiceKey as string == key + && x.Lifetime == ServiceLifetime.Scoped); + } + + [Test] + public async Task AddDataverseClient_Keyed_ConfiguresKeyedOptions() + { + // Arrange + const string key = "source"; + var organizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(key, options => + { + options.OrganizationUrl = organizationUrl; + options.DeferConnection = true; + }); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(key); + + await Assert.That(options.OrganizationUrl).IsEqualTo(organizationUrl); + await Assert.That(options.DeferConnection).IsTrue(); + await Assert.That(options.TokenCredential).IsNull(); + } + + [Test] + public async Task AddDataverseClient_Keyed_ReturnsServiceCollectionForChaining() + { + // Arrange + const string key = "source"; + var services = new ServiceCollection(); + + // Act + var result = services.AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }); + + // Assert + await Assert.That(result).IsSameReferenceAs(services); + } + + [Test] + public async Task AddDataverseClient_Keyed_FromConfiguration_BindsOptionsAndRegistersServices() + { + // Arrange + const string key = "source"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OrganizationUrl"] = "https://my-org.crm4.dynamics.com", + ["DeferConnection"] = "true" + }) + .Build(); + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(key, configuration); + + // Assert — services registered with the correct lifetimes + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == key + && x.Lifetime == ServiceLifetime.Singleton); + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(IOrganizationServiceAsync2) + && x.ServiceKey as string == key + && x.Lifetime == ServiceLifetime.Scoped); + + // Assert — non-secret values bound from configuration + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(key); + + await Assert.That(options.OrganizationUrl) + .IsEqualTo(new Uri("https://my-org.crm4.dynamics.com")); + await Assert.That(options.DeferConnection).IsTrue(); + await Assert.That(options.TokenCredential).IsNull(); + } + + [Test] + public async Task AddDataverseClient_Keyed_DoesNotAffectUnkeyedRegistration() + { + // Arrange + const string key = "source"; + var unkeyedOrganizationUrl = new Uri("https://my-org.crm4.dynamics.com"); + var keyedOrganizationUrl = new Uri("https://keyed-org.crm4.dynamics.com"); + + var provider = new ServiceCollection() + .AddDataverseClient(options => + { + options.OrganizationUrl = unkeyedOrganizationUrl; + options.DeferConnection = true; + }) + .AddDataverseClient(key, options => + { + options.OrganizationUrl = keyedOrganizationUrl; + options.DeferConnection = true; + }) + .BuildServiceProvider(); + + // Act + var unkeyedOptions = provider.GetRequiredService>().Value; + var keyedOptions = provider.GetRequiredService>().Get(key); + var unkeyedClient = provider.GetRequiredService(); + var keyedClient = provider.GetRequiredKeyedService(key); + + // Assert + await Assert.That(unkeyedOptions.OrganizationUrl).IsEqualTo(unkeyedOrganizationUrl); + await Assert.That(keyedOptions.OrganizationUrl).IsEqualTo(keyedOrganizationUrl); + await Assert.That(unkeyedClient).IsNotNull(); + await Assert.That(keyedClient).IsNotNull(); + await Assert.That(ReferenceEquals(keyedClient, unkeyedClient)).IsFalse(); + } + + [Test] + public async Task AddDataverseClient_Keyed_TwoKeyedClientsCoexist() + { + // Arrange + const string source = "source"; + const string target = "target"; + var sourceOrganizationUrl = new Uri("https://source-org.crm4.dynamics.com"); + var targetOrganizationUrl = new Uri("https://target-org.crm4.dynamics.com"); + var services = new ServiceCollection(); + + // Act + services.AddDataverseClient(source, options => + { + options.OrganizationUrl = sourceOrganizationUrl; + options.DeferConnection = true; + }); + services.AddDataverseClient(target, options => + { + options.OrganizationUrl = targetOrganizationUrl; + options.DeferConnection = true; + }); + + // Assert — both keyed registrations exist + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == source + && x.Lifetime == ServiceLifetime.Singleton); + await Assert.That(services) + .Contains(x => x.ServiceType == typeof(ServiceClient) + && x.ServiceKey as string == target + && x.Lifetime == ServiceLifetime.Singleton); + + var provider = services.BuildServiceProvider(); + var optionsMonitor = provider.GetRequiredService>(); + + await Assert.That(optionsMonitor.Get(source).OrganizationUrl).IsEqualTo(sourceOrganizationUrl); + await Assert.That(optionsMonitor.Get(target).OrganizationUrl).IsEqualTo(targetOrganizationUrl); + } + [Test] public async Task AddDataverseClient_FromConfiguration_ThrowsOnStartWhenOrganizationUrlMissing() { @@ -138,6 +333,26 @@ await Assert.That(() => provider.GetRequiredService(); } + [Test] + public async Task AddDataverseClient_Keyed_FromConfiguration_ThrowsOnStartWhenOrganizationUrlMissing() + { + // Arrange + const string key = "source"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["DeferConnection"] = "true" + }) + .Build(); + var provider = new ServiceCollection() + .AddDataverseClient(key, configuration) + .BuildServiceProvider(); + + // Act & Assert + await Assert.That(() => provider.GetRequiredService>().Get(key)) + .Throws(); + } + [Test] public async Task AddDataverseClient_ThrowsOnStartWhenOrganizationUrlIsNotHttps() { @@ -154,5 +369,22 @@ public async Task AddDataverseClient_ThrowsOnStartWhenOrganizationUrlIsNotHttps( await Assert.That(() => provider.GetRequiredService>().Value) .Throws(); } -} + [Test] + public async Task AddDataverseClient_Keyed_ThrowsOnStartWhenOrganizationUrlIsNotHttps() + { + // Arrange + const string key = "source"; + var provider = new ServiceCollection() + .AddDataverseClient(key, options => + { + options.OrganizationUrl = new Uri("http://my-org.crm4.dynamics.com"); + options.DeferConnection = true; + }) + .BuildServiceProvider(); + + // Act & Assert + await Assert.That(() => provider.GetRequiredService>().Get(key)) + .Throws(); + } +} diff --git a/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs b/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs index 1d4d75d..4fa8959 100644 --- a/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs +++ b/Dataverse.Extensions.DependencyInjection/Internal/ServiceClientFactory.cs @@ -37,4 +37,32 @@ public static ServiceClient Create(IServiceProvider serviceProvider) return new ServiceClient(connectionOptions, deferConnection: options.DeferConnection); } + + public static ServiceClient CreateKeyed(IServiceProvider serviceProvider, string key) + { + var options = serviceProvider + .GetRequiredService>() + .Get(key); + + var credential = options.TokenCredential ?? new DefaultAzureCredential(); + + var scope = $"{options.OrganizationUrl.GetLeftPart(UriPartial.Authority)}/.default"; + + var logger = serviceProvider.GetService() + ?.CreateLogger(); + + var connectionOptions = new ConnectionOptions + { + ServiceUri = options.OrganizationUrl, + Logger = logger, + AccessTokenProviderFunctionAsync = async _ => + { + var token = await credential.GetTokenAsync( + new TokenRequestContext([scope]), CancellationToken.None); + return token.Token; + } + }; + + return new ServiceClient(connectionOptions, deferConnection: options.DeferConnection); + } } diff --git a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index f12e53a..279f51b 100644 --- a/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/Dataverse.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -49,6 +49,51 @@ public IServiceCollection AddDataverseClient(IConfiguration configuration) return services.AddDataverseClientCore(); } + /// + /// Registers a keyed singleton and a keyed scoped + /// (via ) + /// in the dependency injection container under the given . + /// + /// The service key used to resolve this client via [FromKeyedServices]. + /// Action to configure for this key. + /// The service collection for chaining. + public IServiceCollection AddDataverseClient(string key, Action configureOptions) + { + // AddOptionsWithValidateOnStart(key) scopes the builder to the named instance; + // Configure(action) without a name argument binds to that same named instance. + services.AddOptionsWithValidateOnStart(key) + .Configure(configureOptions) + .ValidateDataverseClientOptions(); + + return services.AddKeyedDataverseClientCore(key); + } + + /// + /// Registers a keyed singleton and a keyed scoped + /// (via ) + /// in the dependency injection container under the given , + /// binding from the supplied configuration section. + /// + /// The service key used to resolve this client via [FromKeyedServices]. + /// Configuration section to bind from. + /// The service collection for chaining. + /// + /// Only non-secret values bind from configuration (e.g. OrganizationUrl, + /// DeferConnection). Authentication uses + /// by default; to use a custom credential, set + /// via . + /// + public IServiceCollection AddDataverseClient(string key, IConfiguration configuration) + { + // AddOptionsWithValidateOnStart(key) scopes the builder to the named instance; + // Bind(configuration) without a name argument binds to that same named instance. + services.AddOptionsWithValidateOnStart(key) + .Bind(configuration) + .ValidateDataverseClientOptions(); + + return services.AddKeyedDataverseClientCore(key); + } + private IServiceCollection AddDataverseClientCore() { services.AddSingleton(ServiceClientFactory.Create); @@ -59,6 +104,17 @@ private IServiceCollection AddDataverseClientCore() return services; } + + private IServiceCollection AddKeyedDataverseClientCore(string key) + { + services.AddKeyedSingleton(key, + (sp, k) => ServiceClientFactory.CreateKeyed(sp, (string)k!)); + + services.AddKeyedScoped(key, + (sp, k) => sp.GetRequiredKeyedService(k).Clone()); + + return services; + } } private static OptionsBuilder ValidateDataverseClientOptions( @@ -70,4 +126,4 @@ private static OptionsBuilder ValidateDataverseClientOpt "OrganizationUrl must be an absolute URI (e.g. https://my-org.crm4.dynamics.com).") .Validate(o => o.OrganizationUrl is null || o.OrganizationUrl.Scheme == Uri.UriSchemeHttps, "OrganizationUrl must use HTTPS."); -} \ No newline at end of file +} diff --git a/README.md b/README.md index 3904ea9..a8ee4a8 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ [![NuGet Downloads](https://img.shields.io/nuget/dt/BauerApps.Dataverse.Extensions.DependencyInjection)](https://www.nuget.org/packages/BauerApps.Dataverse.Extensions.DependencyInjection) [![License](https://img.shields.io/github/license/LarsBauer/dataverse-serviceclient-extensions)](LICENSE) -Dependency injection extensions for [Microsoft.PowerPlatform.Dataverse.Client](https://www.nuget.org/packages/Microsoft.PowerPlatform.Dataverse.Client). Registers a singleton `ServiceClient` and a scoped `IOrganizationServiceAsync2` (via `Clone()`) — the correct pattern most people get wrong. +Dependency injection extensions for [Microsoft.PowerPlatform.Dataverse.Client](https://www.nuget.org/packages/Microsoft.PowerPlatform.Dataverse.Client). Registers a singleton `ServiceClient` and a scoped `IOrganizationServiceAsync2` (via `Clone()`) with a single method call. ## Features - One-line DI registration for `ServiceClient` with proper singleton + scoped `Clone()` lifecycle +- Keyed (multi-environment) client registration via native .NET keyed DI - Authentication via [Azure.Identity](https://www.nuget.org/packages/Azure.Identity) (`DefaultAzureCredential` by default, any `TokenCredential` supported) - Automatic logger wiring from the DI container - Options validation at startup — fail fast on misconfiguration @@ -118,9 +119,41 @@ builder.Services.PostConfigure(options => options.TokenCredential = new ClientSecretCredential(tenantId, clientId, clientSecret)); ``` +## Keyed clients (multiple environments) + +Use keyed registration when your application needs to connect to more than one Dataverse environment — for example a data migration that reads from a source org and writes to a target org. + +Register each client with a string key: + +```csharp +builder.Services.AddDataverseClient("source", options => +{ + options.OrganizationUrl = new Uri("https://source.crm4.dynamics.com"); +}); + +builder.Services.AddDataverseClient("target", + builder.Configuration.GetSection("Dataverse:Target")); +``` + +Resolve via `[FromKeyedServices]`: + +```csharp +public sealed class MigrationService( + [FromKeyedServices("source")] IOrganizationServiceAsync2 source, + [FromKeyedServices("target")] IOrganizationServiceAsync2 target) +{ + public async Task MigrateAsync() + { + // read from source, write to target + } +} +``` + +Each keyed registration is fully independent — its own singleton `ServiceClient` and its own scoped `IOrganizationServiceAsync2`. Keyed and unkeyed registrations coexist without conflict. + ## Why scoped `IOrganizationServiceAsync2`? -`ServiceClient` is registered as a **singleton** to share the underlying connection, metadata cache, and authentication token. However, using a single instance across concurrent requests can cause issues (e.g., `CallerId` leaking between requests). +`ServiceClient` is registered as a **singleton** to share the underlying connection, metadata cache, and authentication token. However, using a single instance across concurrent requests can cause subtle threading issues. `Clone()` creates a lightweight copy that shares the parent's connection pool but is safe for per-request use. This library registers `IOrganizationServiceAsync2` as **scoped**, so each request gets its own clone automatically.