From d575b199e7eaa4567698947883f4fd285f8d7d66 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 1 Jun 2026 11:55:57 +0200 Subject: [PATCH 1/4] feat: Marten WritableStore backend + retarget to .NET 9/10 (v6.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Cocoar.Configuration.Marten — a tenant-aware (database-per-tenant) Marten/PostgreSQL IStoreBackend for the WritableStore. FromMartenStore() (service-backed, Layer-2) resolves IDocumentStore from DI and routes by accessor.Tenant; combine with .TenantScoped() so each tenant's overlay lives in its own database. Includes self-skipping Testcontainers integration tests. Retarget the library to net9.0;net10.0 (drop net8.0), aligned with .NET 10: Microsoft.Extensions.* and ProtectedData -> 10.0.8, Marten 9.3.4, test projects -> net10.0, global.json -> .NET 10 SDK, and CI setup-dotnet -> 9.0.x + 10.0.x. BREAKING CHANGE: drops .NET 8 support; consumers must target .NET 9 or later. +semver: major Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/01-pr-validation.yml | 4 +- .github/workflows/02-develop-build-alpha.yml | 8 +- .github/workflows/03-publish-prerelease.yml | 8 +- .github/workflows/04-publish-stable.yml | 4 +- CHANGELOG.md | 18 +++++ CLAUDE.md | 5 +- README.md | 2 +- .../Cocoar.Configuration.Marten.csproj | 21 +++++ .../CocoarConfigDocument.cs | 26 ++++++ .../MartenStoreBackend.cs | 69 ++++++++++++++++ .../MartenWritableStoreExtensions.cs | 42 ++++++++++ src/Cocoar.Configuration.slnx | 2 + src/Directory.Build.props | 2 +- src/Directory.Packages.props | 39 +++++---- src/global.json | 2 +- ...ocoar.Configuration.Analyzers.Tests.csproj | 2 +- ...coar.Configuration.AspNetCore.Tests.csproj | 2 +- .../Cocoar.Configuration.Core.Tests.csproj | 2 +- .../Cocoar.Configuration.DI.Tests.csproj | 2 +- .../Cocoar.Configuration.Flags.Tests.csproj | 2 +- .../Cocoar.Configuration.Marten.Tests.csproj | 31 +++++++ .../MartenStoreBackendTests.cs | 81 +++++++++++++++++++ .../PostgresFixture.cs | 74 +++++++++++++++++ ...oar.Configuration.MultiTenant.Tests.csproj | 2 +- ...ocoar.Configuration.Providers.Tests.csproj | 2 +- .../Cocoar.Configuration.Secrets.Tests.csproj | 2 +- ...r.Configuration.ServiceBacked.Tests.csproj | 2 +- website/.vitepress/config.ts | 1 + website/changelog.md | 10 +++ website/guide/di/lifetimes.md | 2 +- website/guide/providers/marten-store.md | 64 +++++++++++++++ website/guide/providers/writable-store.md | 2 + website/reference/packages.md | 36 ++++++--- 33 files changed, 521 insertions(+), 50 deletions(-) create mode 100644 src/Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj create mode 100644 src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs create mode 100644 src/Cocoar.Configuration.Marten/MartenStoreBackend.cs create mode 100644 src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs create mode 100644 src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj create mode 100644 src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs create mode 100644 src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs create mode 100644 website/guide/providers/marten-store.md diff --git a/.github/workflows/01-pr-validation.yml b/.github/workflows/01-pr-validation.yml index 3775458..7fc9db8 100644 --- a/.github/workflows/01-pr-validation.yml +++ b/.github/workflows/01-pr-validation.yml @@ -28,7 +28,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3 diff --git a/.github/workflows/02-develop-build-alpha.yml b/.github/workflows/02-develop-build-alpha.yml index edce1d7..45c608a 100644 --- a/.github/workflows/02-develop-build-alpha.yml +++ b/.github/workflows/02-develop-build-alpha.yml @@ -28,7 +28,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore @@ -59,7 +61,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3 diff --git a/.github/workflows/03-publish-prerelease.yml b/.github/workflows/03-publish-prerelease.yml index 98968cb..7d17509 100644 --- a/.github/workflows/03-publish-prerelease.yml +++ b/.github/workflows/03-publish-prerelease.yml @@ -30,7 +30,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore @@ -68,7 +70,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3 diff --git a/.github/workflows/04-publish-stable.yml b/.github/workflows/04-publish-stable.yml index 4d8a871..e6c27b9 100644 --- a/.github/workflows/04-publish-stable.yml +++ b/.github/workflows/04-publish-stable.yml @@ -59,8 +59,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore @@ -94,8 +94,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/CHANGELOG.md b/CHANGELOG.md index d38d7c1..28b864a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased] + +> **v6.0.0** — major release. + +### Breaking +- **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later. +- `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit. + +### Added +- **`Cocoar.Configuration.Marten`** — opt-in Marten (PostgreSQL) WritableStore backend. `MartenStoreBackend` persists overrides as one `CocoarConfigDocument` per configuration type; `FromMartenStore()` (service-backed, Layer-2) resolves the `IDocumentStore` from DI and, combined with `.TenantScoped()`, gives **database-per-tenant** configuration (each tenant's overlay in its own database). +- **WritableStore `PatchAsync`** — `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as one atomic write and one recompute; single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` delegate to it. + +### Changed +- Configuration **layer merging is now case-insensitive** on property names (via Cocoar.Json.Mutable 1.2.0), consistent with how the effective config is read back. + +### Fixed +- Resetting a secret-typed member no longer throws `NotSupportedException` (removing an override exposes no plaintext). + ## [5.1.0] - 2026-05-31 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 20e3bbd..93c8584 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ dotnet pack ./src -c Release ## Architecture Overview -**Cocoar.Configuration** is a reactive, strongly-typed configuration library for .NET 8.0+. The architecture follows a modular, capabilities-driven design. +**Cocoar.Configuration** is a reactive, strongly-typed configuration library for .NET 9.0+ (multi-targets `net9.0` and `net10.0`). The architecture follows a modular, capabilities-driven design. ### Core Components @@ -72,7 +72,7 @@ capabilityScope.Compose(this).WithPrimary(new ConcreteTypePrimary(...)); SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); ``` -**Zero External Dependencies** - Shipped packages have no non-Microsoft dependencies. The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig : IObservable`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency. +**Zero External Dependencies** - Core shipped packages have no non-Microsoft dependencies. (Opt-in integration packages are the deliberate exception: `Cocoar.Configuration.Marten` takes a Marten dependency. Consumers who don't reference it pay nothing.) The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig : IObservable`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency. **Reactive Tuples** - `IReactiveConfig<(T1, T2)>` provides atomic multi-config updates. Multiple configs always update together, preventing inconsistent state. @@ -90,6 +90,7 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); | `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints (incl. per-tenant), secrets encryption-key endpoints, scoped tenant config adapter | | `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) | | `Cocoar.Configuration.MicrosoftAdapter` | Bridge to existing `IConfiguration` sources | +| `Cocoar.Configuration.Marten` | Marten (PostgreSQL) WritableStore backend (`MartenStoreBackend`, `FromMartenStore()`); tenant-aware via Marten database-per-tenant. Opt-in integration package that takes a Marten dependency. | | `Cocoar.Configuration.Analyzers` | Roslyn analyzers (COCFG001, 002, 003, 005, 006) and source generator (COCFLAG001-003). COCFG004 was removed — enforced by `where T : class` constraint instead. | | `Cocoar.Configuration.Secrets.Cli` | Global .NET tool for encrypting/decrypting secrets in config files | diff --git a/README.md b/README.md index a7e229e..461ca00 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dotnet add package Cocoar.Configuration.DI # + Microsoft.Extensions.D dotnet add package Cocoar.Configuration.AspNetCore # + health endpoints, feature flags ``` -You only need **one** — each includes everything above it. Requires .NET 8+. +You only need **one** — each includes everything above it. Requires .NET 9+. ## Quick Start diff --git a/src/Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj b/src/Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj new file mode 100644 index 0000000..42d9912 --- /dev/null +++ b/src/Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj @@ -0,0 +1,21 @@ + + + + true + enable + enable + Marten (PostgreSQL document store) backend for the Cocoar.Configuration WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database. + configuration;marten;postgresql;postgres;writable-store;multi-tenancy;document-store;dependency-injection + + + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs b/src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs new file mode 100644 index 0000000..68d8f5f --- /dev/null +++ b/src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs @@ -0,0 +1,26 @@ +namespace Cocoar.Configuration.Marten; + +/// +/// The Marten document that persists one WritableStore overlay. There is one document per configuration type: +/// is the storage key (the configuration type's full name) and is the sparse +/// overlay JSON the WritableStore reads and writes. +/// +/// +/// +/// You normally never touch this type directly — stores and loads it. It is public +/// so you can register it with Marten to control schema creation, e.g. options.Schema.For<CocoarConfigDocument>(), +/// rather than relying on Marten's runtime auto-creation. +/// +/// +/// With Marten database-per-tenant the document lives in the tenant's own database: the backend opens the session +/// for the current accessor.Tenant, so each tenant's configuration overlay is isolated by database. +/// +/// +public sealed class CocoarConfigDocument +{ + /// The storage key — the configuration type's full name (e.g. MyApp.Configuration.SmtpSettings). + public string Id { get; set; } = default!; + + /// The sparse overlay JSON (UTF-8 text) for this configuration type. Defaults to an empty object. + public string Json { get; set; } = "{}"; +} diff --git a/src/Cocoar.Configuration.Marten/MartenStoreBackend.cs b/src/Cocoar.Configuration.Marten/MartenStoreBackend.cs new file mode 100644 index 0000000..9657d00 --- /dev/null +++ b/src/Cocoar.Configuration.Marten/MartenStoreBackend.cs @@ -0,0 +1,69 @@ +using System.Text; +using Cocoar.Configuration.Providers; +using global::Marten; + +namespace Cocoar.Configuration.Marten; + +/// +/// An that persists WritableStore overlays as +/// documents in a Marten (PostgreSQL) store. One document per configuration type, keyed by the type's full name. +/// +/// +/// +/// Tenant routing. When constructed with a non-empty tenantId, every read and write opens its session +/// for that tenant. With Marten database-per-tenant (a multi-tenanted ) this routes the +/// document into the tenant's own database — so each tenant's configuration lives in its own DB. A null/blank +/// tenant uses Marten's default tenant (the single-database case). +/// +/// +/// The backend is intentionally stateless and cheap to construct: it holds the (DI-singleton) document store and a +/// tenant id, and opens a short-lived session per operation. That is exactly what the service-backed +/// FromStore rule expects, so it can re-create the backend each recompute without connection-pool churn +/// (Marten/Npgsql owns the pool). +/// +/// +public sealed class MartenStoreBackend : IStoreBackend +{ + private readonly IDocumentStore _store; + private readonly string? _tenantId; + + /// + /// Creates a backend over the given Marten , optionally bound to a tenant. + /// + /// The Marten document store, typically resolved from DI as a singleton. + /// The tenant whose database to read from / write to. null or blank uses + /// Marten's default tenant. Pass accessor.Tenant from a tenant-scoped rule. + public MartenStoreBackend(IDocumentStore store, string? tenantId = null) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _tenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId; + } + + /// + public async Task ReadAsync(string key, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + await using var session = OpenQuerySession(); + var document = await session.LoadAsync(key, ct).ConfigureAwait(false); + return document?.Json is { } json ? Encoding.UTF8.GetBytes(json) : null; + } + + /// + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(data); + + await using var session = OpenSession(); + // Store is an upsert keyed by Id; SaveChangesAsync commits the single document in one transaction. + session.Store(new CocoarConfigDocument { Id = key, Json = Encoding.UTF8.GetString(data) }); + await session.SaveChangesAsync(ct).ConfigureAwait(false); + } + + private IQuerySession OpenQuerySession() + => _tenantId is null ? _store.QuerySession() : _store.QuerySession(_tenantId); + + private IDocumentSession OpenSession() + => _tenantId is null ? _store.LightweightSession() : _store.LightweightSession(_tenantId); +} diff --git a/src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs b/src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs new file mode 100644 index 0000000..0f15cd3 --- /dev/null +++ b/src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs @@ -0,0 +1,42 @@ +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using global::Marten; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.Marten; + +/// +/// Service-backed (Layer-2, ADR-006) rule factory that backs a WritableStore with Marten. Valid only inside +/// UseServiceBackedConfiguration(...) — the rule stays dormant until the host starts and resolves the Marten +/// from the application container at recompute time. +/// +public static class MartenWritableStoreExtensions +{ + /// + /// Backs the configuration type with a Marten () WritableStore. The Marten + /// is resolved from DI and the current accessor.Tenant selects the tenant + /// database — combine with .TenantScoped() for per-tenant, database-per-tenant configuration: + /// + /// builder.UseServiceBackedConfiguration(rules => + /// [ + /// rules.For<TenantSettings>().FromMartenStore().TenantScoped().Build(), + /// ]); + /// + /// This also exposes the IWritableStore<TenantSettings> write facade (per tenant), since it reuses + /// the tenant-keyed WritableStore backend pipeline. + /// + /// The configuration type to populate from Marten. + /// The service-backed provider builder (from UseServiceBackedConfiguration). + public static ProviderRuleBuilder + FromMartenStore(this ServiceBackedProviderBuilder builder) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + + // Reuse the DI package's tenant-keyed, sp-gated FromStore seam: one MartenStoreBackend per tenant, each + // bound to accessor.Tenant so a write lands in that tenant's own database. + return builder.FromStore((sp, accessor) => + new MartenStoreBackend(sp.GetRequiredService(), accessor.Tenant)); + } +} diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 03cdcd5..d6c8ec8 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -11,6 +11,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 963e516..5198f72 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ - net8.0;net9.0 + net9.0;net10.0 latest enable enable diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index e244eec..c63a46d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,31 +6,38 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + all @@ -41,6 +48,6 @@ - + diff --git a/src/global.json b/src/global.json index 93681ff..69e101b 100644 --- a/src/global.json +++ b/src/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.100", "rollForward": "latestMinor", "allowPrerelease": false } diff --git a/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj b/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj index 6d08b4d..4e9b899 100644 --- a/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 latest enable false diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj b/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj index 8574d71..301c311 100644 --- a/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj b/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj index 505bf1b..109e60f 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj b/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj index 4056fab..2a13c6a 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj +++ b/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj b/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj index dc66e7b..654bc81 100644 --- a/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj b/src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj new file mode 100644 index 0000000..d3171ff --- /dev/null +++ b/src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs b/src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs new file mode 100644 index 0000000..1497142 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs @@ -0,0 +1,81 @@ +using System.Text; +using global::Marten; + +namespace Cocoar.Configuration.Marten.Tests; + +/// +/// Integration tests for against a real PostgreSQL instance. They self-skip +/// when Docker is unavailable (see ), so the default suite stays green everywhere. +/// +[Trait("Type", "Integration")] +public sealed class MartenStoreBackendTests(PostgresFixture pg) : IClassFixture +{ + [SkippableFact] + public async Task Write_then_read_round_trips_the_overlay_bytes() + { + Skip.IfNot(pg.Available, pg.SkipReason ?? "Docker not available"); + await using var store = SingleTenantStore(pg.DefaultConnectionString); + var backend = new MartenStoreBackend(store); + + const string key = "MyApp.Settings.SmtpSettings"; + var data = """{"Smtp":{"Port":587}}"""u8.ToArray(); + + await backend.WriteAsync(key, data); + var read = await backend.ReadAsync(key); + + Assert.NotNull(read); + Assert.Equal(Encoding.UTF8.GetString(data), Encoding.UTF8.GetString(read)); + } + + [SkippableFact] + public async Task Read_of_a_missing_key_returns_null() + { + Skip.IfNot(pg.Available, pg.SkipReason ?? "Docker not available"); + await using var store = SingleTenantStore(pg.DefaultConnectionString); + var backend = new MartenStoreBackend(store); + + var read = await backend.ReadAsync("Never.Written.Key"); + + Assert.Null(read); + } + + [SkippableFact] + public async Task Writes_are_isolated_per_tenant_database() + { + Skip.IfNot(pg.Available, pg.SkipReason ?? "Docker not available"); + await using var store = MultiTenantStore(); + var backendA = new MartenStoreBackend(store, PostgresFixture.TenantA); + var backendB = new MartenStoreBackend(store, PostgresFixture.TenantB); + + const string key = "MyApp.Settings.TenantSettings"; + var dataA = """{"Theme":"dark"}"""u8.ToArray(); + + await backendA.WriteAsync(key, dataA); + + // Same key, different tenant database -> invisible to tenant B (database-per-tenant isolation). + Assert.Null(await backendB.ReadAsync(key)); + + // Tenant A reads back its own write. + var readA = await backendA.ReadAsync(key); + Assert.NotNull(readA); + Assert.Equal(Encoding.UTF8.GetString(dataA), Encoding.UTF8.GetString(readA)); + } + + private static IDocumentStore SingleTenantStore(string connectionString) + => DocumentStore.For(opts => + { + opts.Connection(connectionString); + opts.RegisterDocumentType(); + }); + + private IDocumentStore MultiTenantStore() + => DocumentStore.For(opts => + { + opts.MultiTenantedDatabases(x => + { + x.AddSingleTenantDatabase(pg.ConnectionStringForTenantA, PostgresFixture.TenantA); + x.AddSingleTenantDatabase(pg.ConnectionStringForTenantB, PostgresFixture.TenantB); + }); + opts.RegisterDocumentType(); + }); +} diff --git a/src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs b/src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs new file mode 100644 index 0000000..5dddb74 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs @@ -0,0 +1,74 @@ +using Testcontainers.PostgreSql; + +namespace Cocoar.Configuration.Marten.Tests; + +/// +/// Spins up a throwaway PostgreSQL container for the Marten backend tests and creates one database per test +/// tenant (database-per-tenant). If Docker is not reachable, stays false and the tests +/// skip themselves instead of failing — so the default suite stays green on machines/CI without Docker. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + public const string TenantA = "tenant-a"; + public const string TenantB = "tenant-b"; + private const string TenantADatabase = "tenant_a"; + private const string TenantBDatabase = "tenant_b"; + + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithDatabase("postgres") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + /// True once the container is up and the tenant databases exist. + public bool Available { get; private set; } + + /// Why the tests are skipped (Docker not available), or null when is true. + public string? SkipReason { get; private set; } + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + + foreach (var database in new[] { TenantADatabase, TenantBDatabase }) + { + var result = await _container.ExecAsync(["psql", "-U", "postgres", "-c", $"CREATE DATABASE {database};"]); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to create database '{database}': {result.Stderr}"); + } + } + + Available = true; + } + catch (Exception ex) + { + Available = false; + SkipReason = $"Docker / PostgreSQL not available: {ex.Message}"; + } + } + + /// Connection string to the default (single-tenant) database. + public string DefaultConnectionString => _container.GetConnectionString(); + + /// Connection string to a specific tenant's database (database-per-tenant). + public string ConnectionStringForTenantA => ConnectionStringFor(TenantADatabase); + + /// Connection string to a specific tenant's database (database-per-tenant). + public string ConnectionStringForTenantB => ConnectionStringFor(TenantBDatabase); + + private string ConnectionStringFor(string database) + => _container.GetConnectionString() + .Replace("Database=postgres", $"Database={database}", StringComparison.OrdinalIgnoreCase); + + public async Task DisposeAsync() + { + if (Available) + { + await _container.DisposeAsync(); + } + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj b/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj index 593bba7..7de5aab 100644 --- a/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj b/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj index 9f0d413..e2b7eab 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable Cocoar.Configuration.Providers.Tests diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj index f12c6c2..a4585d6 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj index 0b5035a..c2acca8 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index a41d564..2878011 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -68,6 +68,7 @@ export default defineConfig({ { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, { text: 'Writable Store', link: '/guide/providers/writable-store' }, + { text: 'Marten Store', link: '/guide/providers/marten-store' }, { text: 'Custom Providers ', link: '/guide/providers/custom' }, ], }, diff --git a/website/changelog.md b/website/changelog.md index abc84ee..edc6a73 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -2,8 +2,18 @@ ## [Unreleased] +> **v6.0.0** — major release. The headline change is the move off .NET 8. + +### Breaking +- **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later. +- `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit. + ### Added +**Marten store backend** (`Cocoar.Configuration.Marten`) +- New opt-in package: `MartenStoreBackend` persists WritableStore overrides in [Marten](https://martendb.io/) (PostgreSQL) — one `CocoarConfigDocument` per configuration type. +- `FromMartenStore()` service-backed (Layer-2) rule extension resolves the `IDocumentStore` from DI. Combine with `.TenantScoped()` for **database-per-tenant** configuration: each tenant's overlay lives in its own database (Marten multi-tenancy), selected from `accessor.Tenant`. + **WritableStore — batch writes (`PatchAsync`)** - `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as **one** atomic write and **one** recompute — a form save no longer fires one recompute (and one backend round-trip) per field. The single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` now delegate to it. - Async overload `PatchAsync(async b => …)` for when gathering values is asynchronous (e.g. encrypting a `SecretEnvelope` inline). diff --git a/website/guide/di/lifetimes.md b/website/guide/di/lifetimes.md index c338f8c..f833ca6 100644 --- a/website/guide/di/lifetimes.md +++ b/website/guide/di/lifetimes.md @@ -89,7 +89,7 @@ Same as the default — useful for being explicit or overriding an interface's l ## Keyed Services -Register the same configuration type under different keys (requires .NET 8+): +Register the same configuration type under different keys (keyed services, .NET 9+): ```csharp setup.ConcreteType().AsSingleton("primary"), diff --git a/website/guide/providers/marten-store.md b/website/guide/providers/marten-store.md new file mode 100644 index 0000000..5c3b2c1 --- /dev/null +++ b/website/guide/providers/marten-store.md @@ -0,0 +1,64 @@ +# Marten Store + +`Cocoar.Configuration.Marten` is a ready-made [Writable Store](/guide/providers/writable-store) backend that persists overrides in [Marten](https://martendb.io/) (a PostgreSQL document store). Its headline feature is **tenant-aware, database-per-tenant** storage: with Marten multi-tenancy, each tenant's configuration overlay lives in that tenant's own database. + +```shell +dotnet add package Cocoar.Configuration.Marten +``` + +It is an opt-in integration package — it intentionally takes a Marten dependency. Consumers who don't reference it pay nothing. + +## Why it is service-backed + +The backend needs a Marten `IDocumentStore`, which lives in the DI container — so the rule must resolve it *after* the container is built. That is exactly what [service-backed (Layer-2) configuration](/guide/di/service-backed) is for. Author the rule inside `UseServiceBackedConfiguration`, where `FromMartenStore()` is available: + +```csharp +builder.AddCocoarConfiguration(c => c + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromMartenStore().TenantScoped().Build(), + ])); +``` + +`FromMartenStore()` resolves the `IDocumentStore` from DI and uses the current tenant (`accessor.Tenant`) to select the tenant database. The rule stays dormant until the host starts; the document store is never touched before the container exists. + +Because it reuses the writable-store pipeline, you also get the `IWritableStore` write façade (per tenant) for writing overrides at runtime. + +## Tenant-aware, database-per-tenant + +Configure Marten with database-per-tenant multi-tenancy as you normally would (`MultiTenantedDatabases` / `AddSingleTenantDatabase`), then combine `FromMartenStore()` with `.TenantScoped()`: + +```csharp +services.AddMarten(opts => +{ + opts.MultiTenantedDatabases(x => + { + x.AddSingleTenantDatabase(contosoConnectionString, "contoso"); + x.AddSingleTenantDatabase(globexConnectionString, "globex"); + }); + opts.RegisterDocumentType(); +}); +``` + +At recompute time, the backend opens its Marten session for `accessor.Tenant`, so a write for tenant `contoso` lands in Contoso's database and is invisible to `globex`. Each tenant pipeline keeps its own writable store, so overlays never alias across tenants. See [Multi-Tenancy](/guide/multi-tenancy/overview) for how tenant pipelines are built and consumed. + +A `null`/blank tenant uses Marten's default tenant — the single-database case, when you use `FromMartenStore()` without `.TenantScoped()`. + +## Storage model + +Overrides are stored as one [`CocoarConfigDocument`](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Cocoar.Configuration.Marten) per configuration type: + +- `Id` — the storage key (the configuration type's full name, e.g. `MyApp.Settings.TenantSettings`). +- `Json` — the sparse overlay JSON the writable store reads and writes. + +Register the document type with Marten (`RegisterDocumentType()`) so its table is created in each tenant database, or rely on Marten's runtime auto-creation. + +## Reactivity and HA + +A write is reactive **within the writing process**: it signals the provider's change observable and the pipeline recomputes, so every `IReactiveConfig` view on that instance updates. In a multi-instance (HA) deployment all pointing at the same database, a write on instance A does **not** automatically propagate to B/C — cross-instance reactivity needs a database notification (e.g. PostgreSQL `LISTEN/NOTIFY`) routed into the provider's change stream. That is a separate, additive enhancement and is not part of this backend. + +## See also + +- [Writable Store](/guide/providers/writable-store) — the override-layer concept and write API this builds on. +- [Service-Backed Configuration](/guide/di/service-backed) — why DB-backed rules are Layer-2. +- [Multi-Tenancy](/guide/multi-tenancy/overview) — per-tenant pipelines. diff --git a/website/guide/providers/writable-store.md b/website/guide/providers/writable-store.md index da4953e..95472ab 100644 --- a/website/guide/providers/writable-store.md +++ b/website/guide/providers/writable-store.md @@ -227,6 +227,8 @@ rules.For().FromStore((accessor, current) => `ReadAsync` returns empty `{}` when nothing is stored (consistent with the [provider contract](/guide/providers/overview#the-provider-contract)), so an unwritten overlay is an invisible layer. +For a ready-made PostgreSQL backend — including **tenant-aware, database-per-tenant** storage where each tenant's configuration lives in its own database — see the [Marten Store](/guide/providers/marten-store) package. + ## How it works ``` diff --git a/website/reference/packages.md b/website/reference/packages.md index 2d2c008..fcaa9cb 100644 --- a/website/reference/packages.md +++ b/website/reference/packages.md @@ -6,48 +6,48 @@ Lightweight interfaces for decoupled architecture. Reference this from libraries that need to accept configuration without depending on the full implementation. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** None - **Key types:** `IConfigurationAccessor`, `IReactiveConfig`, `ISecret`, `SecretLease` ```xml - + ``` ### Cocoar.Configuration The core library. Includes providers, reactive engine, secrets, feature flags, and entitlements. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration.Abstractions, Cocoar.Configuration.Analyzers (build-time), Cocoar.Capabilities, Cocoar.FileSystem, Cocoar.Json.Mutable, Microsoft.Extensions.Logging.Abstractions - **Key types:** `ConfigManager`, `Secret`, `IFeatureFlags`, `IEntitlements`, `FeatureFlag`, `Entitlement` ```xml - + ``` ### Cocoar.Configuration.DI Microsoft.Extensions.DependencyInjection integration. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration, Cocoar.Capabilities - **Key types:** `AddCocoarConfiguration()` extension method ```xml - + ``` ### Cocoar.Configuration.AspNetCore ASP.NET Core integration — includes DI and adds health checks, feature flag/entitlement REST endpoints. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration, Cocoar.Configuration.DI, Microsoft.AspNetCore.App (FrameworkReference) - **Key types:** `AddCocoarConfigurationHealthCheck()`, `MapFeatureFlagEndpoints()`, `MapEntitlementEndpoints()` ```xml - + ``` ::: tip @@ -58,24 +58,36 @@ AspNetCore includes DI, which includes Core — you only need one `PackageRefere Remote configuration provider with support for one-time fetch, polling, and Server-Sent Events (SSE). Separate package to avoid forcing an HTTP dependency on all consumers. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration - **Key types:** `FromHttp()` extension method, `HttpRuleOptions` ```xml - + ``` ### Cocoar.Configuration.MicrosoftAdapter Bridge from `Microsoft.Extensions.Configuration` sources (Azure Key Vault, custom providers, etc.) into Cocoar.Configuration. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration, Microsoft.Extensions.Configuration.* - **Key types:** `FromIConfiguration()` extension method ```xml - + +``` + +### Cocoar.Configuration.Marten + +Marten (PostgreSQL document store) backend for the WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database. Opt-in package — it intentionally takes a Marten dependency; consumers who don't reference it pay nothing. + +- **Target:** .NET 9.0 / .NET 10.0 +- **Dependencies:** Cocoar.Configuration.DI, Marten +- **Key types:** `MartenStoreBackend`, `CocoarConfigDocument`, `FromMartenStore()` extension method + +```xml + ``` ### Cocoar.Configuration.Analyzers From 8b6bef531ed0fa48bca6b787b436b629df7230cd Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 1 Jun 2026 12:26:19 +0200 Subject: [PATCH 2/4] chore: remove v5-deprecated APIs for v6.0.0 + pin merge behavior A major release is the window to drop APIs deprecated since v5: IConfigurationAccessor.GetRequiredConfig()/(Type) -> GetConfig()/(Type); FromMicrosoftSource() -> FromIConfiguration(); X509CertificateGenerator.GenerateAndSave() -> GenerateAndSavePfx/Pem. All call sites (tests, examples, current docs) migrated. Adds probe tests pinning the case-insensitive layer-merge behavior (verified, kept as-is). Also removed stale local build-output dirs from the working copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + .../Core/IConfigurationAccessor.cs | 20 ---- src/Cocoar.Configuration.Analyzers/README.md | 2 +- .../RulesExtensions.cs | 15 --- .../Core/ConfigManager.cs | 9 -- .../Core/ConfigurationAccessor.cs | 20 ---- .../Providers/CommandLineProvider/README.md | 2 +- .../X509CertificateGenerator.cs | 22 ---- src/Examples/SecretsBasicExample/Program.cs | 2 +- .../SecretsCertificateExample/Program.cs | 4 +- .../Core/SecretsFluentApiTests.cs | 8 +- .../CaseInsensitiveLayerMergeTests.cs | 101 ++++++++++++++++++ .../InterfaceDeserializationTests.cs | 10 +- .../Managers/ConfigManagerIsolationTests.cs | 4 +- .../Testing/CocoarTestConfigurationTests.cs | 10 +- .../Http/HttpProviderSmokeTests.cs | 10 +- .../CertificateExpirationTests.cs | 4 +- .../CertificateFolderTests.cs | 2 +- .../CertificateOrderingTests.cs | 24 ++--- website/changelog.md | 5 + .../guide/configuration/required-optional.md | 4 - 21 files changed, 151 insertions(+), 132 deletions(-) create mode 100644 src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b864a..b37e72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ - **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later. - `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit. +### Removed +- `IConfigurationAccessor.GetRequiredConfig()` / `GetRequiredConfig(Type)` — deprecated since v5; use `GetConfig()` / `GetConfig(Type)` (identical throw-on-missing behavior). +- `FromMicrosoftSource(...)` — use `FromIConfiguration(IConfiguration)`. +- `X509CertificateGenerator.GenerateAndSave(...)` — use `GenerateAndSavePfx(...)` / `GenerateAndSavePem(...)`. + ### Added - **`Cocoar.Configuration.Marten`** — opt-in Marten (PostgreSQL) WritableStore backend. `MartenStoreBackend` persists overrides as one `CocoarConfigDocument` per configuration type; `FromMartenStore()` (service-backed, Layer-2) resolves the `IDocumentStore` from DI and, combined with `.TenantScoped()`, gives **database-per-tenant** configuration (each tenant's overlay in its own database). - **WritableStore `PatchAsync`** — `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as one atomic write and one recompute; single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` delegate to it. diff --git a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs index 9b088b3..d7c16c4 100644 --- a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs +++ b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs @@ -31,16 +31,6 @@ public interface IConfigurationAccessor /// True if configuration exists for the type; false otherwise. bool TryGetConfig(out T? value) where T : class; - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - T GetRequiredConfig(); - /// /// Gets a configuration instance from the cached snapshot. /// @@ -58,16 +48,6 @@ public interface IConfigurationAccessor /// True if configuration exists for the type; false otherwise. bool TryGetConfig(Type type, out object? value); - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - object GetRequiredConfig(Type type); - JsonElement? GetConfigAsJson(Type type); /// diff --git a/src/Cocoar.Configuration.Analyzers/README.md b/src/Cocoar.Configuration.Analyzers/README.md index 1733da9..21382b3 100644 --- a/src/Cocoar.Configuration.Analyzers/README.md +++ b/src/Cocoar.Configuration.Analyzers/README.md @@ -52,7 +52,7 @@ Validates that rules appear after their dependencies. ```csharp // ❌ Error: rule.For() - .When(accessor => accessor.GetRequiredConfig().IsEnabled), + .When(accessor => accessor.GetConfig()!.IsEnabled), rule.For().FromFile("api.json"), // COCFG002: ApiSettings not available - move this rule after ApiSettings rule ``` diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs index 9d5b380..df64d90 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs +++ b/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs @@ -26,19 +26,4 @@ public static _ => new MicrosoftConfigurationProviderQueryOptions(), typeof(T) ); - - /// - /// Creates a Microsoft configuration source rule with custom options. - /// - [Obsolete("Use FromIConfiguration(IConfiguration) instead.")] - public static - ProviderRuleBuilder FromMicrosoftSource(this TypedProviderBuilder builder, - Func optionsFactory) - where T : class - => new( - cm => optionsFactory(cm).ToProviderOptions(), - cm => optionsFactory(cm).ToQueryOptions(), - typeof(T) - ); } diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 22ff400..d3641e3 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -242,21 +242,12 @@ public bool TryGetConfig(out T? value) where T : class return _accessor.TryGetConfig(out value); } -#pragma warning disable CS0618 // Type or member is obsolete - public T GetRequiredConfig() => _accessor.GetRequiredConfig(); -#pragma warning restore CS0618 - /// public object GetConfig(Type type) => _accessor.GetConfig(type); /// public bool TryGetConfig(Type type, out object? value) => _accessor.TryGetConfig(type, out value); -#pragma warning disable CS0618 // Type or member is obsolete - /// - public object GetRequiredConfig(Type type) => _accessor.GetRequiredConfig(type); -#pragma warning restore CS0618 - /// /// Gets the current configuration snapshot for the specified type serialized as a . /// Returns null if no rule is registered for the type. diff --git a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs index 48ef26e..30c1786 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs @@ -186,16 +186,6 @@ public bool TryGetConfig(out T? value) where T : class } } - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - public T GetRequiredConfig() => (T)GetConfig(typeof(T)); - /// /// Gets a configuration instance from the cached snapshot. /// During recompute, falls back to on-demand deserialization. @@ -284,15 +274,5 @@ public bool TryGetConfig(Type type, out object? value) } } - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - public object GetRequiredConfig(Type type) => GetConfig(type); - public JsonElement? GetConfigAsJson(Type type) => _state.GetConfigurationAsJson(type); } diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md index 7bebefa..59735bd 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md @@ -135,7 +135,7 @@ builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ rule.For().FromCommandLine(accessor => { - var tenant = accessor.GetRequiredConfig(); + var tenant = accessor.GetConfig()!; return new CommandLineRuleOptions { Prefix = $"{tenant.Name}_", diff --git a/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs b/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs index 1544043..16a79e3 100644 --- a/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs +++ b/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs @@ -151,28 +151,6 @@ public static X509Certificate2 GenerateAndSavePem( } } - /// - /// Generates a self-signed certificate and saves it as a PFX file. - /// - /// Path where the certificate will be saved. - /// Password for PFX file. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing files; otherwise throws if files exist. - /// The generated certificate. - [Obsolete("Use GenerateAndSavePfx or GenerateAndSavePem instead.")] - public static X509Certificate2 GenerateAndSave( - string outputPath, - string password, - string subject, - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - return GenerateAndSavePfx(outputPath, password, subject, validYears, keySize, overwrite); - } - /// /// Converts a certificate from PFX to PEM format. /// diff --git a/src/Examples/SecretsBasicExample/Program.cs b/src/Examples/SecretsBasicExample/Program.cs index 8b2f613..0babe69 100644 --- a/src/Examples/SecretsBasicExample/Program.cs +++ b/src/Examples/SecretsBasicExample/Program.cs @@ -30,7 +30,7 @@ static void Main() var certPath = Path.Combine(Path.GetTempPath(), "cocoar-secrets-demo.pfx"); // Explicit certificate generation (password-less) - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( certPath, null, // Password-less certificate "CN=Dev Secrets", diff --git a/src/Examples/SecretsCertificateExample/Program.cs b/src/Examples/SecretsCertificateExample/Program.cs index 4f6bad7..ca1b547 100644 --- a/src/Examples/SecretsCertificateExample/Program.cs +++ b/src/Examples/SecretsCertificateExample/Program.cs @@ -37,7 +37,7 @@ static void RunDevelopmentScenario() // Generate a password-less self-signed certificate for development explicitly var devCertPath = Path.Combine(Path.GetTempPath(), "cocoar-dev-demo.pfx"); - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( devCertPath, null, // Password-less certificate "CN=Dev Secrets", @@ -85,7 +85,7 @@ static void RunProductionScenario() // In real production, you'd use: .UseCertificateFromFile("certs/prod.pfx") var prodCertPath = Path.Combine(Path.GetTempPath(), "cocoar-prod-demo.pfx"); - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( prodCertPath, null, // Password-less certificate "CN=Production Secrets", diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs index e7ca022..033c191 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs @@ -22,7 +22,7 @@ public void Secrets_AcceptsFluentConfiguration() try { // Generate certificate explicitly - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath, null, // Password-less certificate "CN=Test Certificate", @@ -58,7 +58,7 @@ public void Secrets_AllowsFluentProtectorConfiguration() try { // Generate certificate explicitly - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath, null, // Password-less certificate "CN=Test Certificate", @@ -106,14 +106,14 @@ public void Secrets_EachConfigManagerGetsIsolatedRuntime() try { // Generate certificates explicitly - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath1, null, // Password-less certificate "CN=Test Certificate 1", validYears: 1, keySize: 2048); - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath2, null, // Password-less certificate "CN=Test Certificate 2", diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs new file mode 100644 index 0000000..098619b --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs @@ -0,0 +1,101 @@ +namespace Cocoar.Configuration.Core.Tests.Integration; + +/// +/// Pins the observable behavior of the case-insensitive layer merge (ConfigMergeOptions.CaseInsensitive), +/// added in PR #50 and first shipping in v6.0.0. Verifies it before release — especially the dictionary case. +/// +public class CaseInsensitiveLayerMergeTests +{ + public sealed class ProbeConfig + { + public int Port { get; set; } + public string? Name { get; set; } + public Dictionary Map { get; set; } = new(); + } + + [Fact] + [Trait("Type", "Unit")] + public void PocoProperty_CaseVariantAcrossLayers_HigherLayerWins_AndOtherKeysSurvive() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "port": 25, "name": "base" }"""), + rules.For().FromStaticJson("""{ "Port": 587 }"""), + ])); + + var cfg = manager.GetConfig()!; + + Assert.Equal(587, cfg.Port); // "Port" overrides "port" (case-insensitive) + Assert.Equal("base", cfg.Name); // untouched lower-layer key survives (deep merge) + } + + [Fact] + [Trait("Type", "Unit")] + public void Value_CasingIsNeverTouched() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Name": "Hello-WORLD" }"""), + ])); + + Assert.Equal("Hello-WORLD", manager.GetConfig()!.Name); + } + + [Fact] + [Trait("Type", "Unit")] + public void Dictionary_DistinctKeysAcrossLayers_DeepMergeKeepsBoth() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Map": { "Foo": "1" } }"""), + rules.For().FromStaticJson("""{ "Map": { "Bar": "2" } }"""), + ])); + + var map = manager.GetConfig()!.Map; + + // Reveals whether nested objects deep-merge (Foo + Bar) or the higher layer replaces (Bar only). + Assert.Equal(2, map.Count); + } + + [Fact] + [Trait("Type", "Unit")] + public void Dictionary_CaseDistinctKeysAcrossLayers_RevealsCollapseBehavior() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Map": { "X-Trace": "a" } }"""), + rules.For().FromStaticJson("""{ "Map": { "x-trace": "b" } }"""), + ])); + + var map = manager.GetConfig()!.Map; + + // CONFIRMED: the case-insensitive merge collapses case-distinct dictionary keys across layers into one. + // The result is a MIX: the higher layer wins the VALUE ("b"), but the LOWER layer's KEY casing + // ("X-Trace") is retained — so the consumer sees neither layer's entry verbatim. + Assert.Equal(1, map.Count); + Assert.Equal("b", map.Single().Value); + Assert.Equal("X-Trace", map.Single().Key); + } + + [Fact] + [Trait("Type", "Unit")] + public void Dictionary_CaseDistinctKeysWithinOneLayer_RevealsParseBehavior() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Map": { "Key": "1", "key": "2" } }"""), + ])); + + var map = manager.GetConfig()!.Map; + + // CONFIRMED (the asymmetry): a SINGLE layer PRESERVES case-distinct dictionary keys (ordinal JSON parse), + // even though the cross-layer MERGE above collapses them. Same data, different result depending on + // whether it arrives in one layer or is split across layers. + Assert.Equal(2, map.Count); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs index 40fe41f..55c9f44 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs @@ -56,7 +56,7 @@ public void Should_Deserialize_Interface_Property_From_StaticJson() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config); @@ -89,7 +89,7 @@ public void Should_Deserialize_Interface_Property_From_Environment_Variables() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config); @@ -157,7 +157,7 @@ public void Should_Handle_Multiple_Interface_Mappings() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config.Logging); @@ -227,7 +227,7 @@ public void Should_Handle_Nested_Interface_Properties() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config); @@ -313,7 +313,7 @@ public void Should_Handle_Deeply_Nested_Interface_Properties() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config.Logging); diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs index ef3bd0a..90e535e 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs @@ -210,7 +210,7 @@ public void GetRequiredConfig_WithValidConfiguration_ShouldReturnValue() var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); TrackForDisposal(configManager); - var result = configManager.GetRequiredConfig(); + var result = configManager.GetConfig()!; Assert.NotNull(result); Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); @@ -233,7 +233,7 @@ public void GetRequiredConfig_WithNonExistentType_ShouldThrowInvalidOperationExc #pragma warning disable CS0618 // Type or member is obsolete var exception = Assert.Throws(() => - configManager.GetRequiredConfig()); + configManager.GetConfig()!); #pragma warning restore CS0618 Assert.Contains("ApiConfig", exception.Message); diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs index 5a1003a..6d44efa 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs @@ -427,7 +427,7 @@ public void ConfigManager_AppliesTestOverrides_ReplaceMode() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert - Test overrides are used Assert.Equal("test-connection", config.Connection); @@ -454,7 +454,7 @@ public void ConfigManager_AppliesTestOverrides_AppendMode() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert - Test override merged (last-write-wins) Assert.Equal(999, config.MaxConnections); @@ -474,7 +474,7 @@ public void ConfigManager_WorksNormally_WhenNoTestOverride() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert - Normal behavior Assert.Equal("normal-connection", config.Connection); @@ -504,7 +504,7 @@ public void ConfigManager_AppliesContextFromApply() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.Equal("applied-connection", config.Connection); @@ -751,7 +751,7 @@ public void ConfigManager_AppliesTestSetupOverrides_FromAppliedContext() // Assert Assert.True(testSetupCalled); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; Assert.Equal("test", config.Value); } diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs index 4cecf56..d3c43e6 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs @@ -51,14 +51,12 @@ public async Task ConfigManager_Recompute_OnChange_Required() var services = new ServiceCollection(); services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ // Provide base settings with Url via in-memory Microsoft IConfigurationSource (adapter) - rules.For().FromMicrosoftSource(cm => new( + rules.For().FromIConfiguration( new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["Remote:Url"] = "https://example.com/api/config" }) - .Sources[0], - configurationPrefix: "Remote" - )), + .AddInMemoryCollection(new Dictionary { ["Url"] = "https://example.com/api/config" }) + .Build()), rules.For().FromHttp(configManager => new( - url: configManager.GetRequiredConfig().Url, + url: configManager.GetConfig()!.Url, // Give CI plenty of time; we will actively wait for the change pollInterval: TimeSpan.FromMilliseconds(50), handler: handler diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs index 8a756ac..36f4f00 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs @@ -28,7 +28,7 @@ public void LoadCertificate_ValidCert_LoadsSuccessfully() { // Generate certificate valid for 1 year (minimum) var certPath = Path.Combine(_tempPath, "valid.pfx"); - X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); + X509CertificateGenerator.GenerateAndSavePfx(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); // Redirect console output to capture any messages using var sw = new StringWriter(); @@ -61,7 +61,7 @@ public void LoadCertificate_NotExpiring_NoWarning() { // Generate certificate valid for 1 year var certPath = Path.Combine(_tempPath, "valid.pfx"); - X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); + X509CertificateGenerator.GenerateAndSavePfx(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); // Redirect console output to verify no warning using var sw = new StringWriter(); diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs index 24ff4ee..0aac760 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs @@ -29,7 +29,7 @@ public void Dispose() private X509Certificate2 GenerateTestCert(string path, string subject) { - X509CertificateGenerator.GenerateAndSave(path, _password, subject, validYears: 1, keySize: 2048, overwrite: true); + X509CertificateGenerator.GenerateAndSavePfx(path, _password, subject, validYears: 1, keySize: 2048, overwrite: true); return CertificateHelper.LoadFromFile(path, _password); } diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs index 98dea5b..630b056 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs @@ -32,9 +32,9 @@ public void DefaultOrdering_DescendingAlphabetical() var cert02Path = Path.Combine(_tempBasePath, "02-middle.pfx"); var cert03Path = Path.Combine(_tempBasePath, "03-new.pfx"); - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + var cert01 = X509CertificateGenerator.GenerateAndSavePfx(cert01Path, _password, "CN=Cert01"); + var cert02 = X509CertificateGenerator.GenerateAndSavePfx(cert02Path, _password, "CN=Cert02"); + var cert03 = X509CertificateGenerator.GenerateAndSavePfx(cert03Path, _password, "CN=Cert03"); try { @@ -61,11 +61,11 @@ public void CustomComparer_LastWriteTimeDescending() var cert02Path = Path.Combine(_tempBasePath, "cert-b.pfx"); var cert03Path = Path.Combine(_tempBasePath, "cert-c.pfx"); - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); + var cert01 = X509CertificateGenerator.GenerateAndSavePfx(cert01Path, _password, "CN=Cert01"); Thread.Sleep(100); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); + var cert02 = X509CertificateGenerator.GenerateAndSavePfx(cert02Path, _password, "CN=Cert02"); Thread.Sleep(100); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + var cert03 = X509CertificateGenerator.GenerateAndSavePfx(cert03Path, _password, "CN=Cert03"); try { @@ -95,9 +95,9 @@ public void CustomComparer_NumericSuffixDescending() var cert02Path = Path.Combine(_tempBasePath, "cert.02.pfx"); var cert03Path = Path.Combine(_tempBasePath, "cert.03.pfx"); - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + var cert01 = X509CertificateGenerator.GenerateAndSavePfx(cert01Path, _password, "CN=Cert01"); + var cert02 = X509CertificateGenerator.GenerateAndSavePfx(cert02Path, _password, "CN=Cert02"); + var cert03 = X509CertificateGenerator.GenerateAndSavePfx(cert03Path, _password, "CN=Cert03"); try { @@ -140,9 +140,9 @@ public void CustomComparer_ByFileSize_LargestFirst() var mediumCertPath = Path.Combine(_tempBasePath, "medium.pfx"); var largeCertPath = Path.Combine(_tempBasePath, "large.pfx"); - var smallCert = X509CertificateGenerator.GenerateAndSave(smallCertPath, _password, "CN=Small", keySize: 2048); - var mediumCert = X509CertificateGenerator.GenerateAndSave(mediumCertPath, _password, "CN=Medium", keySize: 3072); - var largeCert = X509CertificateGenerator.GenerateAndSave(largeCertPath, _password, "CN=Large", keySize: 4096); + var smallCert = X509CertificateGenerator.GenerateAndSavePfx(smallCertPath, _password, "CN=Small", keySize: 2048); + var mediumCert = X509CertificateGenerator.GenerateAndSavePfx(mediumCertPath, _password, "CN=Medium", keySize: 3072); + var largeCert = X509CertificateGenerator.GenerateAndSavePfx(largeCertPath, _password, "CN=Large", keySize: 4096); try { diff --git a/website/changelog.md b/website/changelog.md index edc6a73..cb92ab0 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -8,6 +8,11 @@ - **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later. - `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit. +### Removed +- `IConfigurationAccessor.GetRequiredConfig()` / `GetRequiredConfig(Type)` — deprecated since v5; use `GetConfig()` / `GetConfig(Type)` (identical throw-on-missing behavior). +- `FromMicrosoftSource(...)` — use `FromIConfiguration(IConfiguration)`. +- `X509CertificateGenerator.GenerateAndSave(...)` — use `GenerateAndSavePfx(...)` / `GenerateAndSavePem(...)`. + ### Added **Marten store backend** (`Cocoar.Configuration.Marten`) diff --git a/website/guide/configuration/required-optional.md b/website/guide/configuration/required-optional.md index dc34948..8127806 100644 --- a/website/guide/configuration/required-optional.md +++ b/website/guide/configuration/required-optional.md @@ -87,10 +87,6 @@ rule.For().FromFile("premium.json") .When(accessor => accessor.GetConfig()!.IsPremium) ``` -:::warning GetRequiredConfig is deprecated -`GetRequiredConfig()` still exists but is deprecated — it has identical behavior to `GetConfig()`. Use `GetConfig()` in all new code. -::: - ## Startup vs Runtime Behavior | Scenario | Startup | Runtime Recompute | From 44ccf22a07efed3fe16bdc46f59e86b32ab2b331 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 1 Jun 2026 14:15:34 +0200 Subject: [PATCH 3/4] refactor: rename package to Cocoar.Configuration.WritableStore.Marten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is specifically the Marten WritableStore backend, not a general 'all things Marten' package — the explicit name signals that concern (vs the ambiguous Cocoar.Configuration.Marten). Renames the project, test project, namespaces, solution entries, and all doc/changelog references. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- CLAUDE.md | 4 ++-- .../Cocoar.Configuration.WritableStore.Marten.csproj} | 0 .../CocoarConfigDocument.cs | 2 +- .../MartenStoreBackend.cs | 2 +- .../MartenWritableStoreExtensions.cs | 2 +- src/Cocoar.Configuration.slnx | 4 ++-- .../Cocoar.Configuration.WritableStore.Marten.Tests.csproj} | 2 +- .../MartenStoreBackendTests.cs | 2 +- .../PostgresFixture.cs | 2 +- website/changelog.md | 2 +- website/guide/providers/marten-store.md | 6 +++--- website/reference/packages.md | 4 ++-- 13 files changed, 17 insertions(+), 17 deletions(-) rename src/{Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj => Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj} (100%) rename src/{Cocoar.Configuration.Marten => Cocoar.Configuration.WritableStore.Marten}/CocoarConfigDocument.cs (96%) rename src/{Cocoar.Configuration.Marten => Cocoar.Configuration.WritableStore.Marten}/MartenStoreBackend.cs (98%) rename src/{Cocoar.Configuration.Marten => Cocoar.Configuration.WritableStore.Marten}/MartenWritableStoreExtensions.cs (97%) rename src/tests/{Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj => Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj} (87%) rename src/tests/{Cocoar.Configuration.Marten.Tests => Cocoar.Configuration.WritableStore.Marten.Tests}/MartenStoreBackendTests.cs (98%) rename src/tests/{Cocoar.Configuration.Marten.Tests => Cocoar.Configuration.WritableStore.Marten.Tests}/PostgresFixture.cs (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b37e72a..71728ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - `X509CertificateGenerator.GenerateAndSave(...)` — use `GenerateAndSavePfx(...)` / `GenerateAndSavePem(...)`. ### Added -- **`Cocoar.Configuration.Marten`** — opt-in Marten (PostgreSQL) WritableStore backend. `MartenStoreBackend` persists overrides as one `CocoarConfigDocument` per configuration type; `FromMartenStore()` (service-backed, Layer-2) resolves the `IDocumentStore` from DI and, combined with `.TenantScoped()`, gives **database-per-tenant** configuration (each tenant's overlay in its own database). +- **`Cocoar.Configuration.WritableStore.Marten`** — opt-in Marten (PostgreSQL) WritableStore backend. `MartenStoreBackend` persists overrides as one `CocoarConfigDocument` per configuration type; `FromMartenStore()` (service-backed, Layer-2) resolves the `IDocumentStore` from DI and, combined with `.TenantScoped()`, gives **database-per-tenant** configuration (each tenant's overlay in its own database). - **WritableStore `PatchAsync`** — `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as one atomic write and one recompute; single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` delegate to it. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 93c8584..dfca97a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ capabilityScope.Compose(this).WithPrimary(new ConcreteTypePrimary(...)); SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); ``` -**Zero External Dependencies** - Core shipped packages have no non-Microsoft dependencies. (Opt-in integration packages are the deliberate exception: `Cocoar.Configuration.Marten` takes a Marten dependency. Consumers who don't reference it pay nothing.) The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig : IObservable`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency. +**Zero External Dependencies** - Core shipped packages have no non-Microsoft dependencies. (Opt-in integration packages are the deliberate exception: `Cocoar.Configuration.WritableStore.Marten` takes a Marten dependency. Consumers who don't reference it pay nothing.) The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig : IObservable`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency. **Reactive Tuples** - `IReactiveConfig<(T1, T2)>` provides atomic multi-config updates. Multiple configs always update together, preventing inconsistent state. @@ -90,7 +90,7 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); | `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints (incl. per-tenant), secrets encryption-key endpoints, scoped tenant config adapter | | `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) | | `Cocoar.Configuration.MicrosoftAdapter` | Bridge to existing `IConfiguration` sources | -| `Cocoar.Configuration.Marten` | Marten (PostgreSQL) WritableStore backend (`MartenStoreBackend`, `FromMartenStore()`); tenant-aware via Marten database-per-tenant. Opt-in integration package that takes a Marten dependency. | +| `Cocoar.Configuration.WritableStore.Marten` | Marten (PostgreSQL) WritableStore backend (`MartenStoreBackend`, `FromMartenStore()`); tenant-aware via Marten database-per-tenant. Opt-in integration package that takes a Marten dependency. | | `Cocoar.Configuration.Analyzers` | Roslyn analyzers (COCFG001, 002, 003, 005, 006) and source generator (COCFLAG001-003). COCFG004 was removed — enforced by `where T : class` constraint instead. | | `Cocoar.Configuration.Secrets.Cli` | Global .NET tool for encrypting/decrypting secrets in config files | diff --git a/src/Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj b/src/Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj similarity index 100% rename from src/Cocoar.Configuration.Marten/Cocoar.Configuration.Marten.csproj rename to src/Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj diff --git a/src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs b/src/Cocoar.Configuration.WritableStore.Marten/CocoarConfigDocument.cs similarity index 96% rename from src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs rename to src/Cocoar.Configuration.WritableStore.Marten/CocoarConfigDocument.cs index 68d8f5f..208989d 100644 --- a/src/Cocoar.Configuration.Marten/CocoarConfigDocument.cs +++ b/src/Cocoar.Configuration.WritableStore.Marten/CocoarConfigDocument.cs @@ -1,4 +1,4 @@ -namespace Cocoar.Configuration.Marten; +namespace Cocoar.Configuration.WritableStore.Marten; /// /// The Marten document that persists one WritableStore overlay. There is one document per configuration type: diff --git a/src/Cocoar.Configuration.Marten/MartenStoreBackend.cs b/src/Cocoar.Configuration.WritableStore.Marten/MartenStoreBackend.cs similarity index 98% rename from src/Cocoar.Configuration.Marten/MartenStoreBackend.cs rename to src/Cocoar.Configuration.WritableStore.Marten/MartenStoreBackend.cs index 9657d00..bdec85c 100644 --- a/src/Cocoar.Configuration.Marten/MartenStoreBackend.cs +++ b/src/Cocoar.Configuration.WritableStore.Marten/MartenStoreBackend.cs @@ -2,7 +2,7 @@ using Cocoar.Configuration.Providers; using global::Marten; -namespace Cocoar.Configuration.Marten; +namespace Cocoar.Configuration.WritableStore.Marten; /// /// An that persists WritableStore overlays as diff --git a/src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs b/src/Cocoar.Configuration.WritableStore.Marten/MartenWritableStoreExtensions.cs similarity index 97% rename from src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs rename to src/Cocoar.Configuration.WritableStore.Marten/MartenWritableStoreExtensions.cs index 0f15cd3..76ca39f 100644 --- a/src/Cocoar.Configuration.Marten/MartenWritableStoreExtensions.cs +++ b/src/Cocoar.Configuration.WritableStore.Marten/MartenWritableStoreExtensions.cs @@ -4,7 +4,7 @@ using global::Marten; using Microsoft.Extensions.DependencyInjection; -namespace Cocoar.Configuration.Marten; +namespace Cocoar.Configuration.WritableStore.Marten; /// /// Service-backed (Layer-2, ADR-006) rule factory that backs a WritableStore with Marten. Valid only inside diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index d6c8ec8..1c22214 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -11,7 +11,7 @@ - + @@ -39,7 +39,7 @@ - + diff --git a/src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj similarity index 87% rename from src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj rename to src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj index d3171ff..c298f52 100644 --- a/src/tests/Cocoar.Configuration.Marten.Tests/Cocoar.Configuration.Marten.Tests.csproj +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/MartenStoreBackendTests.cs similarity index 98% rename from src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs rename to src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/MartenStoreBackendTests.cs index 1497142..b3fc78b 100644 --- a/src/tests/Cocoar.Configuration.Marten.Tests/MartenStoreBackendTests.cs +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/MartenStoreBackendTests.cs @@ -1,7 +1,7 @@ using System.Text; using global::Marten; -namespace Cocoar.Configuration.Marten.Tests; +namespace Cocoar.Configuration.WritableStore.Marten.Tests; /// /// Integration tests for against a real PostgreSQL instance. They self-skip diff --git a/src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs similarity index 97% rename from src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs rename to src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs index 5dddb74..7f17ab0 100644 --- a/src/tests/Cocoar.Configuration.Marten.Tests/PostgresFixture.cs +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs @@ -1,6 +1,6 @@ using Testcontainers.PostgreSql; -namespace Cocoar.Configuration.Marten.Tests; +namespace Cocoar.Configuration.WritableStore.Marten.Tests; /// /// Spins up a throwaway PostgreSQL container for the Marten backend tests and creates one database per test diff --git a/website/changelog.md b/website/changelog.md index cb92ab0..e02018f 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -15,7 +15,7 @@ ### Added -**Marten store backend** (`Cocoar.Configuration.Marten`) +**Marten store backend** (`Cocoar.Configuration.WritableStore.Marten`) - New opt-in package: `MartenStoreBackend` persists WritableStore overrides in [Marten](https://martendb.io/) (PostgreSQL) — one `CocoarConfigDocument` per configuration type. - `FromMartenStore()` service-backed (Layer-2) rule extension resolves the `IDocumentStore` from DI. Combine with `.TenantScoped()` for **database-per-tenant** configuration: each tenant's overlay lives in its own database (Marten multi-tenancy), selected from `accessor.Tenant`. diff --git a/website/guide/providers/marten-store.md b/website/guide/providers/marten-store.md index 5c3b2c1..c1a1034 100644 --- a/website/guide/providers/marten-store.md +++ b/website/guide/providers/marten-store.md @@ -1,9 +1,9 @@ # Marten Store -`Cocoar.Configuration.Marten` is a ready-made [Writable Store](/guide/providers/writable-store) backend that persists overrides in [Marten](https://martendb.io/) (a PostgreSQL document store). Its headline feature is **tenant-aware, database-per-tenant** storage: with Marten multi-tenancy, each tenant's configuration overlay lives in that tenant's own database. +`Cocoar.Configuration.WritableStore.Marten` is a ready-made [Writable Store](/guide/providers/writable-store) backend that persists overrides in [Marten](https://martendb.io/) (a PostgreSQL document store). Its headline feature is **tenant-aware, database-per-tenant** storage: with Marten multi-tenancy, each tenant's configuration overlay lives in that tenant's own database. ```shell -dotnet add package Cocoar.Configuration.Marten +dotnet add package Cocoar.Configuration.WritableStore.Marten ``` It is an opt-in integration package — it intentionally takes a Marten dependency. Consumers who don't reference it pay nothing. @@ -46,7 +46,7 @@ A `null`/blank tenant uses Marten's default tenant — the single-database case, ## Storage model -Overrides are stored as one [`CocoarConfigDocument`](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Cocoar.Configuration.Marten) per configuration type: +Overrides are stored as one [`CocoarConfigDocument`](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Cocoar.Configuration.WritableStore.Marten) per configuration type: - `Id` — the storage key (the configuration type's full name, e.g. `MyApp.Settings.TenantSettings`). - `Json` — the sparse overlay JSON the writable store reads and writes. diff --git a/website/reference/packages.md b/website/reference/packages.md index fcaa9cb..12440ed 100644 --- a/website/reference/packages.md +++ b/website/reference/packages.md @@ -78,7 +78,7 @@ Bridge from `Microsoft.Extensions.Configuration` sources (Azure Key Vault, custo ``` -### Cocoar.Configuration.Marten +### Cocoar.Configuration.WritableStore.Marten Marten (PostgreSQL document store) backend for the WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database. Opt-in package — it intentionally takes a Marten dependency; consumers who don't reference it pay nothing. @@ -87,7 +87,7 @@ Marten (PostgreSQL document store) backend for the WritableStore. Persists writa - **Key types:** `MartenStoreBackend`, `CocoarConfigDocument`, `FromMartenStore()` extension method ```xml - + ``` ### Cocoar.Configuration.Analyzers From 5bfc9555d1a1429ea88dbf3d69045a1b34470066 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 1 Jun 2026 14:34:26 +0200 Subject: [PATCH 4/4] fix(test): skip Marten tests when Docker is unavailable PostgreSqlBuilder.Build() validates Docker availability and throws DockerUnavailableException. It ran in the fixture's field initializer (outside the try), so on Docker-less runners (GitHub macOS) class-fixture init failed instead of skipping. Build the container inside InitializeAsync's try so the throw is caught -> Available=false -> tests skip cleanly. (Windows already skipped: its Docker endpoint exists so Build() passed and StartAsync failed inside the try.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PostgresFixture.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs index 7f17ab0..d600032 100644 --- a/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs @@ -14,11 +14,10 @@ public sealed class PostgresFixture : IAsyncLifetime private const string TenantADatabase = "tenant_a"; private const string TenantBDatabase = "tenant_b"; - private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() - .WithDatabase("postgres") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); + // Built inside InitializeAsync, NOT in a field initializer: PostgreSqlBuilder.Build() validates Docker + // availability and throws DockerUnavailableException when no Docker daemon is reachable (e.g. GitHub + // macOS runners). Constructing it inside the try lets that throw be caught so the tests skip, not fail. + private PostgreSqlContainer? _container; /// True once the container is up and the tenant databases exist. public bool Available { get; private set; } @@ -30,6 +29,12 @@ public async Task InitializeAsync() { try { + _container = new PostgreSqlBuilder() + .WithDatabase("postgres") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + await _container.StartAsync(); foreach (var database in new[] { TenantADatabase, TenantBDatabase }) @@ -51,8 +56,8 @@ public async Task InitializeAsync() } } - /// Connection string to the default (single-tenant) database. - public string DefaultConnectionString => _container.GetConnectionString(); + /// Connection string to the default (single-tenant) database. Only valid when . + public string DefaultConnectionString => _container!.GetConnectionString(); /// Connection string to a specific tenant's database (database-per-tenant). public string ConnectionStringForTenantA => ConnectionStringFor(TenantADatabase); @@ -61,12 +66,12 @@ public async Task InitializeAsync() public string ConnectionStringForTenantB => ConnectionStringFor(TenantBDatabase); private string ConnectionStringFor(string database) - => _container.GetConnectionString() + => _container!.GetConnectionString() .Replace("Database=postgres", $"Database={database}", StringComparison.OrdinalIgnoreCase); public async Task DisposeAsync() { - if (Available) + if (_container is not null) { await _container.DisposeAsync(); }