Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
882 changes: 125 additions & 757 deletions README.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# DotNetBoost.Settings documentation

New here? Start with the [5-minute quick start](../README.md#get-started-in-5-minutes).

**Setting up**
- [Defining settings](defining-settings.md): `[SettingGroup]`, group names, default values
- [Storage providers](storage-providers.md): EF Core, Dapper, MongoDB
- [Reading and writing settings](reading-and-writing.md): the accessor API, concurrent writes

**Features**
- [Encrypting sensitive values](encryption.md): `[Sensitive]`, AES-256-GCM, key rotation
- [Validation](validation.md): Data Annotations and FluentValidation
- [Change notifications](change-notifications.md): run code when a setting changes
- [Audit trail](audit-trail.md): who changed what, and when
- [Caching](caching.md): cache duration, Redis for multi-server deployments
- [REST API endpoints](rest-api.md): generated endpoints and **how to secure them**

**Tooling**
- [Dashboard (SPA client)](dashboard.md): a web UI for editing settings
- [Running everything with .NET Aspire](aspire.md): the full demo stack in one command

**Reference**
- [Configuration reference](configuration-reference.md): every `AddSettings()` builder method
- [Architecture](architecture.md): how the pieces fit, repo layout, running the tests
67 changes: 67 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Architecture

```
┌──────────────────────────────────────────────────────────────────┐
│ Your Application │
│ ISettingManager.For<T>() → ISettingAccessor<T> │
└───────────────────────────┬────────────────────────────────────┘
│
SettingManager
┌───────────┬────────┼────────┬─────────────┐
│ │ │ │ │
ISettingCache ISettingStore ISettingEncryptor ISettingAuditStore
(IMemoryCache (EF Core / (AES-256-GCM (EfCoreAuditStore
or Redis) Dapper / or custom) or custom)
MongoDB)
│
ISettingChangedHandler<T>
(your app's runtime reactions)
```

## Repository layout

```
dotnetboost/
├── src/ # Library projects (each = 1 NuGet package)
│ ├── DotNetBoost.Settings.Core/
│ ├── DotNetBoost.Settings.EntityFrameworkCore/
│ ├── DotNetBoost.Settings.Dapper/
│ ├── DotNetBoost.Settings.MongoDb/
│ ├── DotNetBoost.Settings.FluentValidation/
│ └── DotNetBoost.Settings.API/
├── tests/
│ ├── DotNetBoost.Settings.UnitTests/ # Core logic, mocked stores
│ ├── DotNetBoost.Settings.ProviderTests/ # Store contract, in-process SQLite
│ ├── DotNetBoost.Settings.IntegrationTests/ # Same contract, real engines (needs Docker)
│ └── DotNetBoost.Settings.ApiTests/ # Minimal-API endpoints via TestHost
├── samples/
│ └── SampleApp/ # Runnable end-to-end demo
├── clients/
│ └── dashboard/ # Nuxt 4 settings dashboard (SPA client)
├── aspire/
│ ├── DotNetBoost.Settings.AppHost/ # Orchestrates API + dashboard + containers
│ └── DotNetBoost.Settings.ServiceDefaults/ # OTel, health checks, service discovery
├── docs/
├── .github/
│ ├── workflows/ci.yml
│ └── ISSUE_TEMPLATE/
├── Directory.Build.props # Shared MSBuild settings (net10.0, analyzers)
├── Directory.Packages.props # Central package version management
├── DotNetBoost.Settings.sln
├── aspire.config.json # Points `aspire run` at the AppHost
├── CHANGELOG.md
├── CONTRIBUTING.md
└── README.md
```

## Testing

```bash
dotnet test # everything (integration tests need Docker)
dotnet test --filter "Category!=Integration" # skip the container-backed suites
dotnet test --collect:"XPlat Code Coverage" # with coverage
dotnet test tests/DotNetBoost.Settings.ProviderTests # store contract on SQLite only
```

Adding a new store provider? Inherit `SettingStoreContractTests` and implement `CreateStoreAsync()` — you get 24 behavioural tests (upsert, delete, count, exists, etc.) for free, guaranteeing your provider behaves identically to the built-in ones.

90 changes: 90 additions & 0 deletions docs/aspire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Running everything with .NET Aspire

[`aspire/DotNetBoost.Settings.AppHost`](../aspire/DotNetBoost.Settings.AppHost) orchestrates the whole
stack — API, dashboard and every backing service — from one command:

```bash
dotnet run --project aspire/DotNetBoost.Settings.AppHost
```

That needs nothing installed beyond the .NET 10 SDK and a running container engine (Docker or
Podman) — the AppHost pulls the orchestrator and dashboard from NuGet via `dnx aspire.cli` on first
run. Watch the console for the dashboard link; it carries a one-time login token.

The Aspire CLI is optional and shortens the command to `aspire run`. Install it whichever way suits
you — the first needs no elevation and no new tool chain:

```bash
dotnet tool install -g Aspire.Cli
```

```bash
brew install --cask microsoft/aspire/aspire
```

Also available as `npm install -g @microsoft/aspire-cli`, `winget install Microsoft.Aspire`, or the
script at [get.aspire.dev](https://get.aspire.dev). `aspire run` works from anywhere in the repo —
[`aspire.config.json`](../aspire.config.json) points it at the AppHost.

What comes up:

| Resource | What it is |
|---|---|
| `postgres` / `settingsdb` | PostgreSQL 18 container, named volume, persistent across runs |
| `pgweb` | Browser SQL client for inspecting `Settings` and `SettingAudits` |
| `cache` | Redis container backing `RedisSettingCache` |
| `redisinsight` | Browser UI for watching the cache fill and expire |
| `api` | `samples/SampleApp` — the REST endpoints and the Scalar reference at `/scalar` |
| `dashboard-installer` | `npm install` for the Nuxt client, run once before the dev server |
| `dashboard` | `clients/dashboard` — `npm run dev`, wired to the API automatically |

The Aspire dashboard lists every resource with its URL, console output, environment, and the
OpenTelemetry traces, metrics and structured logs the API emits through
[`DotNetBoost.Settings.ServiceDefaults`](../aspire/DotNetBoost.Settings.ServiceDefaults).

Nothing is configured by hand. The AppHost injects `ConnectionStrings__settingsdb` and
`ConnectionStrings__cache` into the API, and `NUXT_SETTINGS_API_URL` /
`NUXT_PUBLIC_API_REFERENCE_URL` into the dashboard, so the values in
`clients/dashboard/.env.example` are only needed when you run the two halves separately.

## Switching the storage provider

The AppHost starts PostgreSQL and the API talks to it through EF Core. Every other provider is
written out in full and commented, so switching is four edits and no new code:

| File | What to change |
|---|---|
| `aspire/…/AppHost.cs` | Comment the active provider block, uncomment another |
| `aspire/…/DotNetBoost.Settings.AppHost.csproj` | Uncomment that provider's `Aspire.Hosting.*` package |
| `samples/SampleApp/SampleApp.csproj` | Uncomment the matching `ItemGroup` |
| `samples/SampleApp/Program.cs` | Comment the active block, uncomment the matching one |

The available blocks are PostgreSQL (EF Core, active), SQL Server (EF Core), SQLite (EF Core, no
container), MongoDB, and Dapper on PostgreSQL. For the EF Core ones, also flip the
`DatabaseProvider` passed to `ApplySettingsConfiguration` in `samples/SampleApp/AppDbContext.cs` —
it decides the column type used for `Value` and how `RowVersion` is mapped.

## The Redis cache

`samples/SampleApp/Caching/RedisSettingCache.cs` is a real
[`ISettingCache`](../src/DotNetBoost.Settings.Core/Interfaces/ISettingCache.cs) over Redis, registered
with `.UseCustomCache<RedisSettingCache>()`. It exists because the default `IMemoryCache` gives every
API instance its own copy: a write on one leaves the others serving stale values until their entry
expires. Redis makes the update visible fleet-wide at once. Drop the `AddRedisClient("cache")` and
`.UseCustomCache<…>()` lines in `Program.cs`, plus the `cache` resource in `AppHost.cs`, to fall back
to the in-memory cache.

## Secrets

The AES key protecting `[Sensitive]` properties comes from the `settings-encryption-key` parameter,
which the AppHost passes to the API as `Settings__EncryptionKey`. Its development value lives in
`aspire/DotNetBoost.Settings.AppHost/appsettings.json` — a throwaway key for local containers only.
Override it anywhere real:

```bash
dotnet user-secrets set Parameters:settings-encryption-key "$(openssl rand -base64 32)" --project aspire/DotNetBoost.Settings.AppHost
```

Rotating the key makes values already encrypted under the old one unreadable, so the persistent
Postgres volume and the key belong together.

24 changes: 24 additions & 0 deletions docs/audit-trail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Audit trail

When an audit store is registered, `SetAsync` records the before/after value, property key, and timestamp for **each property whose value actually changed**. Properties identical to what is already stored are not written to the trail, so saving a model with one edited field produces one entry rather than one per property.

```csharp
builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
.UseAuditStore<EfCoreAuditStore>() // ships with the EF Core package
.Build();
```

Query history directly:

```csharp
IReadOnlyList<SettingAuditEntry> history =
await auditStore.GetHistoryAsync("MailSettings", key: "Host");
```

Or via the REST API — every settings group automatically gets a `GET /api/settings/{route}/audit` endpoint. The audit store is optional: without one the endpoint returns `404` with an explanatory body, and the rest of the settings API is unaffected.

Values marked `[Sensitive]` are recorded as `[encrypted]` on both sides of the entry rather than in cleartext. Change detection compares them as plaintext, not as stored ciphertext: AES-GCM draws a fresh nonce on every call, so an unchanged secret is re-encrypted to different bytes each save and a ciphertext comparison would log a spurious change every time.

Write your own store (SQL table, Elasticsearch, whatever) by implementing `ISettingAuditStore`.

34 changes: 34 additions & 0 deletions docs/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Caching

Reads go through `ISettingCache` (default: `IMemoryCache`, 10-minute absolute expiration). A per-group `SemaphoreSlim` prevents cache stampedes under concurrent load.

```csharp
builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
.WithCacheDuration(TimeSpan.FromMinutes(5))
.UseCustomCache<RedisSettingCache>() // swap in a distributed cache
.Build();
```

> **Multi-node note:** the default cache is per-instance in-memory. In a multi-node deployment, use `UseCustomCache<T>()` with a distributed cache (Redis, etc.) so a write on one node invalidates the cache on all nodes.

```csharp
public class RedisSettingCache(IConnectionMultiplexer redis) : ISettingCache
{
private readonly IDatabase _db = redis.GetDatabase();

public bool TryGetValue<T>(string key, out T? value)
{
var raw = _db.StringGet(key);
if (!raw.HasValue) { value = default; return false; }
value = JsonSerializer.Deserialize<T>(raw!);
return value is not null;
}

public void Set<T>(string key, T value, TimeSpan duration)
=> _db.StringSet(key, JsonSerializer.Serialize(value), duration);

public void Remove(string key) => _db.KeyDelete(key);
}
```

27 changes: 27 additions & 0 deletions docs/change-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Change notifications

React to settings changes at runtime — no restart needed to pick up a new SMTP host, feature flag, or rate limit.

```csharp
public class MailSettingsChangedHandler(ILogger<MailSettingsChangedHandler> logger)
: ISettingChangedHandler<MailSettings>
{
public Task OnChangedAsync(MailSettings previous, MailSettings current, CancellationToken ct = default)
{
logger.LogInformation("Mail host changed: {Old} -> {New}", previous.Host, current.Host);
// Rebuild your SmtpClient pool, refresh a cached connection, etc.
return Task.CompletedTask;
}
}
```

```csharp
builder.Services.AddScoped<MailSettingsChangedHandler>();
builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
.OnChanged<MailSettings, MailSettingsChangedHandler>()
.Build();
```

Multiple handlers per settings type are supported and run in registration order. A handler that throws is logged and does not roll back the write or block other handlers.

20 changes: 20 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Configuration reference

## `AddSettings()` builder methods

| Method | Package | Description |
|---|---|---|
| `.UseEntityFrameworkCore<TContext>()` | EntityFrameworkCore | Backing store |
| `.UseDapper(factory, migrateSchema)` | Dapper | Backing store |
| `.UseMongoDb(connStr, dbName, createIndexes)` | MongoDb | Backing store, provider-owned client |
| `.UseMongoDb(databaseFactory, createIndexes)` | MongoDb | Backing store, reusing your own `IMongoClient` |
| `.UseCustomCache<TCache>()` | Core | Replace the default cache |
| `.WithCacheDuration(TimeSpan)` | Core | Override the 10-minute default |
| `.UseAesEncryption(key, retiredKeys...)` | Core | Enable built-in AES-256-GCM encryption; retired keys decrypt only |
| `.IgnoreDecryptionFailures()` | Core | Fall back to defaults instead of throwing when a value will not decrypt |
| `.UseCustomEncryption<TEncryptor>()` | Core | Plug in a custom encryptor |
| `.UseAuditStore<TStore>()` | Core (+ EF Core impl) | Enable change history |
| `.OnChanged<TSettings, THandler>()` | Core | Register a runtime change handler |
| `.UseFluentValidation(assembly)` | FluentValidation | Register validators |
| `.Build()` | Core | Validate configuration, return `IServiceCollection` |

56 changes: 56 additions & 0 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Dashboard (SPA client)

[`clients/dashboard`](../clients/dashboard) is a [Nuxt 4](https://nuxt.com) single-page client for
those endpoints, built on the [Nuxt UI dashboard template](https://github.com/nuxt-ui-templates/dashboard).
A sidebar links to **Settings**, where a top menu holds one entry per settings group — each one
reads and writes its own group.

```bash
dotnet run --project samples/SampleApp --urls http://localhost:5199 # the API
npm install --prefix clients/dashboard && npm run dev --prefix clients/dashboard
```

The dashboard is at `http://localhost:3000`, the API reference at `http://localhost:5199/scalar`.
Or start both — plus PostgreSQL and Redis — with one `dotnet run`; see
[Running everything with .NET Aspire](aspire.md).

## One group, end to end

`MailSettings` from [`samples/SampleApp`](../samples/SampleApp) — the class, its `[Sensitive]`
property and its `MailSettingsValidator` — as the dashboard renders it:

![The Mail Settings group in the dashboard: SMTP host, port, an SSL switch and a masked sensitive password field](../assets/dashboard-mail-settings.png)

The form is built from whatever `GET api/settings/mail-server` returned. `UseSsl` is a `bool`, so
it renders as a switch; `Password` is `[Sensitive]`, so it is masked behind a reveal toggle and the
value never touches the database unencrypted.

![Mail Settings after a save: the password revealed, and a toast reading "Mail Settings saved — the new values are live, no redeploy needed"](../assets/dashboard-mail-settings-saved.png)

`Save changes` sends the whole group back with the `ETag` from the load as `If-Match`, so a save
that lost a race is refused rather than silently overwriting the other writer. The saved values are
live for the running application immediately.

![Mail Settings with an empty SMTP Host: the field is outlined in red with the message 'Host' must not be empty](../assets/dashboard-mail-settings-rejected.png)

Clear the host and the API rejects the write. Each `ValidationProblemDetails` message comes back
attached to the property it names — `RuleFor(x => x.Host).NotEmpty()` in `MailSettingsValidator`
lands under **SMTP Host**, with nothing written.

| File | Role |
|---|---|
| `app/utils/settings.ts` | Group registry — `route` must match `[SettingGroup("…")]` |
| `app/composables/useSettingsGroup.ts` | Load, dirty-tracking, save, server-error mapping |
| `app/components/settings/GroupForm.vue` | The form for one group |
| `app/pages/settings.vue` | Top menu, one entry per group |
| `server/api/settings/[...path].ts` | Nitro proxy to the .NET API |

Form fields are generated from whatever `GET` returns, so a property added to a C# settings class
shows up without touching the client — the registry only supplies labels and input constraints.
Booleans render as switches, `[Sensitive]` properties as masked inputs with a reveal toggle, and a
rejected `POST` has each `ValidationProblemDetails` message attached to the property it names.

The browser never calls the API directly: Nitro proxies `/api/settings/**` to
`NUXT_SETTINGS_API_URL`, so the API needs no CORS configuration. See
[`clients/dashboard/README.md`](../clients/dashboard/README.md) for the full setup.

Loading
Loading