Skip to content

Commit 8f6e645

Browse files
CopilotLarsBauer
andauthored
feat: add keyed Dataverse client registration (#9)
* Initial plan * feat: add named keyed Dataverse client registration * fix: correct named options binding and rename Named to Keyed * refactor: rename CreateNamed to CreateKeyed in ServiceClientFactory * refactor: rename Named to Keyed in tests and update README and AGENTS docs * fix: remove invalid name argument from Configure and Bind on OptionsBuilder --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lars Bauer <larsbauer91@gmail.com>
1 parent 69525b6 commit 8f6e645

6 files changed

Lines changed: 461 additions & 23 deletions

File tree

AGENTS.md

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@
22

33
## Project overview
44

5-
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.
5+
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()`).
66

77
## Scope & non-goals
88

9-
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.
9+
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.
1010

11-
- **In scope**: DI registration (singleton client + scoped `Clone()`), authentication (Azure.Identity + options pattern), logger wiring, startup validation.
12-
- **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.
11+
- **In scope**: DI registration (singleton client + scoped `Clone()`), keyed (multi-environment) client registration, authentication (Azure.Identity + options pattern), logger wiring, startup validation.
12+
- **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.
1313

14-
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.
14+
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.
1515

16-
## Architecture (4 source files)
16+
## Architecture
1717

18-
- `ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads (one taking `Action<DataverseClientOptions>`, one taking `IConfiguration` to bind from a config section) using C# 14 `extension` blocks. Shared wiring/validation live in private static helpers (`AddDataverseClientCore`, `ValidateDataverseClientOptions`).
18+
- `ServiceCollectionExtensions.cs` — Public API surface. `AddDataverseClient()` overloads: unkeyed (one taking `Action<DataverseClientOptions>`, one taking `IConfiguration`) and keyed (same two overloads with a leading `string key` parameter). Uses C# 14 `extension` block style.
1919
- `DataverseClientOptions.cs` — Options POCO with `OrganizationUrl` (required), `TokenCredential`, `DeferConnection`.
20-
- `Internal/ServiceClientFactory.cs`Singleton factory wiring `Azure.Identity` credentials into `ConnectionOptions.AccessTokenProviderFunctionAsync`. Marked `internal`, tested via `InternalsVisibleTo`.
20+
- `Internal/ServiceClientFactory.cs`Factory with two methods: `Create` (unkeyed, uses `IOptions<T>`) and `CreateKeyed` (uses `IOptionsMonitor<T>.Get(key)`). Marked `internal`, tested via `InternalsVisibleTo`.
2121
- Root namespace is `BauerApps.Dataverse.Extensions` (set via `<RootNamespace>` in csproj, differs from folder name).
2222

2323
## Build & test
@@ -27,31 +27,33 @@ dotnet build Dataverse.Extensions.slnx
2727
dotnet test Dataverse.Extensions.slnx
2828
```
2929

30-
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"`).
30+
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.
3131

3232
## Conventions
3333

3434
- **Namespace**: `BauerApps.Dataverse.Extensions` (library), `BauerApps.Dataverse.Extensions.Tests` (tests). Mirror `Internal/` subfolder in both.
3535
- **Internal access**: `InternalsVisibleTo` is configured via `<AssemblyAttribute>` in csproj, not `AssemblyInfo.cs`.
3636
- **Test structure**: Mirrors source layout. `ServiceCollectionExtensionsTests.cs` at root, `Internal/ServiceClientFactoryTests.cs` for internal classes. Tests use Arrange/Act/Assert with comments.
3737
- **Test pattern**: All tests set `DeferConnection = true` to avoid real Dataverse connections. Use `FakeTokenCredential` (private nested class) when testing custom credentials.
38+
- **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.
3839
- **C# 14 features**: Uses `extension` blocks (not classic `static` extension methods). Keep this style for new extensions.
39-
- **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.*`.
40+
- **Dependencies**: `Azure.Identity`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions` (for the `IConfiguration` binding overload), `Microsoft.PowerPlatform.Dataverse.Client`.
41+
- **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.
4042

4143
## CI/CD & versioning
4244

43-
- **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 `<Version>` via the `<!-- x-release-please-version -->` marker comment.
45+
- **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 `<Version>` in the csproj by release-please — never edit these manually.
4446
- **Commit messages**: Use [Conventional Commits](https://www.conventionalcommits.org/)`feat:` (minor bump), `fix:` (patch), `feat!:` or `BREAKING CHANGE` footer (major).
4547
- **CI** (`.github/workflows/ci.yml`): Builds and tests on every push/PR to `main`.
46-
- **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).
48+
- **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.
4749

4850
## Release process
4951

50-
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.
52+
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.
5153

5254
**Configuration**:
5355

54-
- `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 `<Version>` (marked with `<!-- x-release-please-version -->`) is updated on release. Changelog is written to `Dataverse.Extensions.DependencyInjection/CHANGELOG.md`.
56+
- `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`).
5557
- `.release-please-manifest.json` — the current released version, keyed by package directory. Do not edit by hand; release-please maintains it.
5658

5759
**Commit message rules** (these determine the next version bump):
@@ -73,13 +75,19 @@ Notes:
7375
1. Open a PR with Conventional Commit message(s) and merge to `main`.
7476
2. release-please opens (or updates) a **Release PR** that bumps the version, updates `CHANGELOG.md`, and updates `.release-please-manifest.json`.
7577
3. Review and merge the Release PR. This creates the GitHub release + `vX.Y.Z` tag.
76-
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`.
78+
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.
7779

7880
## Key patterns
7981

8082
When adding new configuration options:
8183
1. Add property to `DataverseClientOptions`
82-
2. Add validation in `ServiceCollectionExtensions.AddDataverseClient()` via `.Validate()` if required
83-
3. Wire it in `ServiceClientFactory.Create()` using the options pattern (`IOptions<T>`)
84+
2. Add validation in `ServiceCollectionExtensions` via `.Validate()` if required
85+
3. Wire it in `ServiceClientFactory` using the appropriate options pattern (`IOptions<T>` for unkeyed, `IOptionsMonitor<T>` for keyed)
8486
4. Test both the registration (service descriptor assertions) and the factory behavior
87+
5. Update `README.md` with usage examples and `AGENTS.md` with any architecture or convention changes
8588

89+
When adding a new keyed registration:
90+
- Use `AddKeyedSingleton` / `AddKeyedScoped` — do NOT reuse the unkeyed core path
91+
- Thread the key through via the keyed service factory delegate `(sp, key) => ...`
92+
- Use `IOptionsMonitor<T>.Get(key)` — never `IOptions<T>` for keyed registrations
93+
- Keep unkeyed and keyed paths fully independent to avoid `Options.DefaultName` collisions

Dataverse.Extensions.DependencyInjection.Tests/Internal/ServiceClientFactoryTests.cs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Azure.Core;
2+
using BauerApps.Dataverse.Extensions.Internal;
23
using Microsoft.Extensions.DependencyInjection;
34
using Microsoft.Extensions.Options;
45
using Microsoft.PowerPlatform.Dataverse.Client;
@@ -80,6 +81,88 @@ public async Task Create_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue
8081
await Assert.That(client).IsNotNull();
8182
}
8283

84+
[Test]
85+
public async Task CreateKeyed_UsesKeyedOptions()
86+
{
87+
// Arrange
88+
const string key = "source";
89+
var services = new ServiceCollection();
90+
services.AddDataverseClient(key, options =>
91+
{
92+
options.OrganizationUrl = new Uri("https://keyed-org.crm4.dynamics.com");
93+
options.DeferConnection = true;
94+
});
95+
var provider = services.BuildServiceProvider();
96+
97+
// Act
98+
var client = ServiceClientFactory.CreateKeyed(provider, key);
99+
100+
// Assert
101+
await Assert.That(client).IsNotNull();
102+
}
103+
104+
[Test]
105+
public async Task CreateKeyed_UsesDefaultAzureCredentialWhenTokenCredentialIsNull()
106+
{
107+
// Arrange
108+
const string key = "source";
109+
var services = new ServiceCollection();
110+
services.AddDataverseClient(key, options =>
111+
{
112+
options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com");
113+
options.DeferConnection = true;
114+
});
115+
var provider = services.BuildServiceProvider();
116+
117+
// Act
118+
var client = ServiceClientFactory.CreateKeyed(provider, key);
119+
120+
// Assert — client is created without throwing (deferred connection)
121+
await Assert.That(client).IsNotNull();
122+
}
123+
124+
[Test]
125+
public async Task CreateKeyed_UsesCustomTokenCredential()
126+
{
127+
// Arrange
128+
const string key = "source";
129+
var customCredential = new FakeTokenCredential();
130+
var services = new ServiceCollection();
131+
services.AddDataverseClient(key, options =>
132+
{
133+
options.OrganizationUrl = new Uri("https://my-org.crm4.dynamics.com");
134+
options.TokenCredential = customCredential;
135+
options.DeferConnection = true;
136+
});
137+
var provider = services.BuildServiceProvider();
138+
139+
// Act
140+
var client = ServiceClientFactory.CreateKeyed(provider, key);
141+
142+
// Assert
143+
await Assert.That(client).IsNotNull();
144+
}
145+
146+
[Test]
147+
public async Task CreateKeyed_DoesNotThrowWhenConnectionFailsAndDeferConnectionIsTrue()
148+
{
149+
// Arrange
150+
const string key = "source";
151+
var services = new ServiceCollection();
152+
services.AddDataverseClient(key, options =>
153+
{
154+
options.OrganizationUrl = new Uri("https://invalid-org.crm4.dynamics.com");
155+
options.DeferConnection = true;
156+
});
157+
var provider = services.BuildServiceProvider();
158+
159+
// Act
160+
var client = ServiceClientFactory.CreateKeyed(provider, key);
161+
162+
// Assert — deferred connection should not throw
163+
await Assert.That(client).IsNotNull();
164+
}
165+
83166
/// <summary>
84167
/// Minimal fake TokenCredential for testing.
85168
/// </summary>
@@ -92,5 +175,3 @@ public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext request
92175
=> new(new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1)));
93176
}
94177
}
95-
96-

0 commit comments

Comments
 (0)