From b89322dba9657810376208e97688120e30bdb602 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Sat, 19 Sep 2026 21:22:47 +0300 Subject: [PATCH] Rewrite the README around a runnable quick start and move reference docs to docs/ The old Quick Start could not be followed as written: only a prerelease is on NuGet, so `dotnet add package` without --prerelease fails, and the snippet used an AppDbContext it never defined. The new README leads with a single-file EF Core + SQLite app that was verified against the published 1.0.0-preview.1 packages, then links out to per-topic pages under docs/. docs/validation.md now states that Data Annotations are only enforced by the REST endpoint, not by SetAsync(), and adds the FluentValidation package install step. Co-Authored-By: Claude Opus 5 --- README.md | 882 +++++--------------------------- docs/README.md | 24 + docs/architecture.md | 67 +++ docs/aspire.md | 90 ++++ docs/audit-trail.md | 24 + docs/caching.md | 34 ++ docs/change-notifications.md | 27 + docs/configuration-reference.md | 20 + docs/dashboard.md | 56 ++ docs/defining-settings.md | 74 +++ docs/encryption.md | 66 +++ docs/reading-and-writing.md | 108 ++++ docs/rest-api.md | 57 +++ docs/storage-providers.md | 85 +++ docs/validation.md | 75 +++ 15 files changed, 932 insertions(+), 757 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/aspire.md create mode 100644 docs/audit-trail.md create mode 100644 docs/caching.md create mode 100644 docs/change-notifications.md create mode 100644 docs/configuration-reference.md create mode 100644 docs/dashboard.md create mode 100644 docs/defining-settings.md create mode 100644 docs/encryption.md create mode 100644 docs/reading-and-writing.md create mode 100644 docs/rest-api.md create mode 100644 docs/storage-providers.md create mode 100644 docs/validation.md diff --git a/README.md b/README.md index 936cdd6..7b6b1da 100644 --- a/README.md +++ b/README.md @@ -3,867 +3,235 @@ [![CI](https://github.com/dotnetboost/settings/actions/workflows/ci.yml/badge.svg)](https://github.com/dotnetboost/settings/actions/workflows/ci.yml) [![NuGet](https://img.shields.io/nuget/v/DotNetBoost.Settings.Core.svg)](https://www.nuget.org/packages/DotNetBoost.Settings.Core) [![.NET](https://img.shields.io/badge/.NET-8.0%20%7C%2010.0-512BD4)](https://dotnet.microsoft.com/) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/dotnetboost/settings/blob/main/LICENSE) -A strongly-typed, database-backed **runtime settings manager** for .NET 8 and .NET 10. Store application settings in a database instead of `appsettings.json` so they can be **read and changed at runtime without redeployment** — with caching, encryption, audit trail, change notifications, validation, and auto-generated REST endpoints. +**Change your app's settings while it is running, with no redeploy and no restart.** ---- - -## Table of Contents - -- [Why?](#why) -- [Packages](#packages) -- [Quick Start](#quick-start) -- [Defining Settings](#defining-settings) -- [Group names](#group-names) -- [Registering Services](#registering-services) -- [Reading & Writing Settings](#reading--writing-settings) -- [Concurrent writes](#concurrent-writes) -- [Caching](#caching) -- [Encryption for Sensitive Values](#encryption-for-sensitive-values) -- [Change Notifications](#change-notifications) -- [Audit Trail](#audit-trail) -- [Validation](#validation) -- [REST API Endpoints](#rest-api-endpoints) -- [Dashboard (SPA client)](#dashboard-spa-client) -- [Running everything with .NET Aspire](#running-everything-with-net-aspire) -- [Default Values](#default-values) -- [Architecture](#architecture) -- [Repository Layout](#repository-layout) -- [Configuration Reference](#configuration-reference) -- [Testing](#testing) -- [Contributing](#contributing) - ---- - -## Why? - -`appsettings.json` is static — changing it requires a redeploy or restart. `DotNetBoost.Settings` persists settings in a database instead: - -| Feature | appsettings.json | DotNetBoost.Settings | -|---|---|---| -| Strongly-typed POCO | ✅ (Options pattern) | ✅ | -| Change at runtime, no redeploy | ❌ | ✅ | -| Multiple database backends | ❌ | ✅ EF Core / Dapper / MongoDB | -| Built-in REST API | ❌ | ✅ | -| Validation on write | Limited | ✅ enforced on every write path | -| Automatic caching | ❌ | ✅ with stampede protection | -| Encryption for secrets | ❌ | ✅ `[Sensitive]` + AES-256-GCM | -| Change history / audit | ❌ | ✅ pluggable `ISettingAuditStore` | -| Runtime change notifications | ❌ | ✅ `ISettingChangedHandler` | - ---- - -## Packages - -| Package | Description | -|---|---| -| `DotNetBoost.Settings.Core` | Core engine — `ISettingManager`, caching, encryption, audit, change notifications | -| `DotNetBoost.Settings.EntityFrameworkCore` | EF Core provider (SQL Server, PostgreSQL, SQLite) + audit store | -| `DotNetBoost.Settings.Dapper` | Dapper provider (SQL Server, PostgreSQL, SQLite) | -| `DotNetBoost.Settings.MongoDb` | MongoDB provider | -| `DotNetBoost.Settings.FluentValidation` | FluentValidation integration | -| `DotNetBoost.Settings.API` | Auto-generated REST endpoints | - -All packages multi-target **`net8.0`** (LTS) and **`net10.0`**. - -On `net8.0` the EF Core provider resolves the EF Core 8.x family; on `net10.0` it resolves 10.x. Nothing in your application has to change either way. - -```bash -dotnet add package DotNetBoost.Settings.Core -dotnet add package DotNetBoost.Settings.EntityFrameworkCore # or Dapper / MongoDb -``` - ---- - -## Quick Start +You write a plain C# class. DotNetBoost.Settings stores it in your database, caches it, and gives +you a REST endpoint to edit it. The next read in your app sees the new value. ```csharp -// 1. Define your settings [SettingGroup("mail-server")] public class MailSettings { - public string Host { get; set; } = "smtp.example.com"; - public int Port { get; set; } = 587; - public bool UseSsl { get; set; } = true; -} - -// 2. Register -builder.Services.AddSettings() - .UseEntityFrameworkCore() - .Build(); - -// 3. Use -app.MapGet("/test", async (ISettingManager settings) => -{ - var mail = await settings.For().GetAsync(); - return $"SMTP host: {mail.Host}:{mail.Port}"; -}); -``` - -A fully working example with encryption, validation, audit, and change notifications lives in [`samples/SampleApp`](samples/SampleApp). - ---- - -## Defining Settings - -```csharp -[SettingGroup("payment", Name = "PaymentSettings")] -public class PaymentSettings -{ - public string GatewayUrl { get; set; } = "https://gateway.example.com"; - - [Sensitive] // encrypted at rest - public string ApiKey { get; set; } = string.Empty; - - [SettingDefault(10_000)] // used when no row exists yet - public decimal MaxAmount { get; set; } = 10_000m; - - public bool SandboxMode { get; set; } = true; + public string Host { get; set; } = "smtp.example.com"; + public int Port { get; set; } = 587; } -``` - -**Rules:** -- Group name must be unique application-wide (see [Group names](#group-names) below). -- `[SettingGroup]` route value must be unique and non-empty. -- Violations throw `InvalidOperationException` at `Build()` time — fail fast at startup, not at 2am in production. - ---- - -## Group names - -`[SettingGroup]` carries two independent identifiers, and it is worth knowing which is which: - -| | What it controls | Safe to change? | -|---|---|---| -| `route` (positional) | The URL segment: `/api/settings/{route}` | Yes — it is a URL, no stored data depends on it | -| `Name` | The **storage key** every row for this group is written under | No — changing it strands the existing rows | - -`Name` is optional and **defaults to the class name**, which is the historical behaviour. Adding the attribute or upgrading the package never moves existing data. - -Setting it explicitly is recommended, because without it your database schema is silently coupled to a C# identifier: - -```csharp -[SettingGroup("mail-server", Name = "MailSettings")] -public class MailSettings { /* ... */ } -``` - -With `Name` pinned, the class can be renamed, moved to another namespace, or reorganised freely and it keeps reading the same rows. Without it, a rename that looks like pure refactoring silently orphans every stored value and the application quietly comes back up on defaults — including default credentials. - -### Migrating an existing group - -Adding `Name` to a class that **already has rows** requires renaming those rows, because you are changing the key they are stored under. Do it in the same deploy as the code change: - -```sql -UPDATE Settings SET SettingGroup = 'new-name' WHERE SettingGroup = 'OldClassName'; -UPDATE SettingAudits SET SettingGroup = 'new-name' WHERE SettingGroup = 'OldClassName'; -``` -```js -// MongoDB -db.settings.updateMany({ Group: "OldClassName" }, { $set: { Group: "new-name" } }) +var mail = await settings.For().GetAsync(); // always the current value ``` -If you set `Name` to exactly the current class name — as the samples above do — there is nothing to migrate, and you gain the freedom to rename the class later. - -Two groups resolving to the same name would read and write each other's rows, so `Build()` rejects it at startup. That check compares resolved names, which means two same-named classes in different namespaces still collide unless one of them sets a distinct `Name`. +Works with **.NET 8 and .NET 10**, on **SQL Server, PostgreSQL, SQLite or MongoDB**. --- -## Concurrent writes - -`SetAsync` writes **only the properties whose values differ from what is stored**, and each of -those writes is conditional on an optimistic concurrency token (`Setting.RowVersion`). Every -provider enforces it: SQL Server, PostgreSQL, SQLite and MongoDB. A write whose token no longer -matches throws `SettingConcurrencyException` rather than silently overwriting. - -```csharp -try -{ - await settings.For().SetAsync(model); -} -catch (SettingConcurrencyException ex) -{ - // ex.Group / ex.Key name the property that moved. Re-read, re-apply, retry. -} -``` - -### Across a read-edit-write cycle +## Get started in 5 minutes -Per-row tokens cannot, on their own, protect an edit made *by your application* — a settings -POCO carries no record of the revision it was loaded at, so a stale copy of a field is -indistinguishable from a deliberate edit. The revision therefore has to travel out to the -caller and back. +This builds a minimal web app that stores its settings in a local SQLite file. Nothing else to install. -Over HTTP that is an entity tag, and the generated endpoints do it for you: +**1. Create a project and add the packages** -```http -GET /api/settings/mail-server -200 OK -ETag: "9f2c1a7b3e5d0148" +(`--prerelease` is needed until 1.0.0 is released.) -POST /api/settings/mail-server -If-Match: "9f2c1a7b3e5d0148" -→ 204 No Content if the group is still at that revision -→ 412 Precondition Failed if someone saved in between -``` - -A POST without `If-Match` writes unconditionally, so existing clients keep working. Once every -client round-trips the tag, make it mandatory — a POST without the header is then rejected with -`428 Precondition Required`: - -```csharp -app.MapSettingsEndpoints(requireIfMatch: true); -``` - -Programmatic callers get the same thing through the accessor: - -```csharp -var version = await settings.For().GetVersionAsync(); -var model = await settings.For().GetAsync(); -model.Host = "smtp.new.example.com"; -await settings.For().SetAsync(model, version); // throws if the group moved +```bash +dotnet new web -n MyApp +cd MyApp +dotnet add package DotNetBoost.Settings.Core --prerelease +dotnet add package DotNetBoost.Settings.EntityFrameworkCore --prerelease +dotnet add package DotNetBoost.Settings.API --prerelease +dotnet add package Microsoft.EntityFrameworkCore.Sqlite ``` -> The check runs against the same snapshot the writes are built from, and the per-row tokens -> still guard the individual UPDATEs — so there is no window between checking the version and -> applying the change. - -The bundled dashboard does this already: it keeps the `ETag` from the load and sends it on -save. When the API refuses with `412` it does **not** discard your edits — it shows what -happened and offers two ways out: - -- **Re-apply my changes** — re-reads the group, keeps the other writer's edits to fields you did - not touch, replays only your own on top, and saves against the fresh revision. -- **Discard mine and reload** — throws your edits away and starts from the current values. - -Because the SPA reaches the API through its own Nitro proxy, the `ETag` is same-origin and -readable from JavaScript without `Access-Control-Expose-Headers`. - ---- - -## Registering Services - -### Entity Framework Core +**2. Replace `Program.cs` with this** ```csharp -public class AppDbContext(DbContextOptions options) - : DbContext(options), ISettingDbContext -{ - public DbSet Settings => Set(); - public DbSet SettingAudits => Set(); +using DotNetBoost.Settings.Core.Attributes; +using DotNetBoost.Settings.Core.Interfaces; +using DotNetBoost.Settings.Core.Models; +using DotNetBoost.Settings.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; - protected override void OnModelCreating(ModelBuilder mb) - => mb.ApplySettingsConfiguration(DatabaseProvider.Sqlite); - // Options: SqlServer | PostgreSql | Sqlite -} -``` +var builder = WebApplication.CreateBuilder(args); -```csharp -builder.Services.AddDbContext(o => o.UseSqlite("Data Source=app.db")); +// Store settings in SQLite through EF Core. +builder.Services.AddDbContext(o => o.UseSqlite("Data Source=settings.db")); builder.Services.AddSettings() .UseEntityFrameworkCore() .Build(); -``` - -Run `dotnet ef migrations add Init && dotnet ef database update` — this creates both the `Settings` and `SettingAudits` tables. - -### Dapper - -```csharp -builder.Services.AddSettings() - .UseDapper(sp => new SqlConnection(connectionString), migrateSchema: true) - .Build(); -``` -Supports `SqlConnection`, `NpgsqlConnection`, and `SqliteConnection`. `migrateSchema: true` auto-creates `Settings` and `SettingAudits` tables on startup. +var app = builder.Build(); -### MongoDB - -```csharp -builder.Services.AddSettings() - .UseMongoDb("mongodb://localhost:27017", "my_app_db") - .Build(); -``` - -The unique `(Group, Key)` index the store relies on is created once at startup by a hosted service. Pass `createIndexes: false` if the application's MongoDB user has no index-creation rights or you manage the index out of band — the store still assumes it exists. - -**If your application already has a MongoDB client** — through .NET Aspire, or its own registration — pass a factory instead, so the settings store shares it rather than opening a second one: - -```csharp -builder.AddMongoDBClient("settingsdb"); // Aspire, or your own AddSingleton - -builder.Services.AddSettings() - .UseMongoDb(sp => sp.GetRequiredService().GetDatabase("my_app_db")) - .Build(); -``` - -Either overload keeps `IMongoClient` and `IMongoDatabase` out of the container: the provider holds its database privately, so it can neither override nor be overridden by your application's own Mongo registration — whichever order they happen in. - ---- - -## Reading & Writing Settings - -```csharp -public class EmailService(ISettingManager settings) +// Create the database and the settings tables on first run. +// In a real app, use EF Core migrations instead. +using (var scope = app.Services.CreateScope()) { - public async Task CreateClientAsync() - { - var mail = await settings.For().GetAsync(); - return new SmtpClient(mail.Host, mail.Port) { EnableSsl = mail.UseSsl }; - } - - public async Task GetPortAsync() - => await settings.For().GetAsync(x => x.Port); - - public async Task UpdatePortAsync(int port) - => await settings.For().SetAsync(x => x.Port, port); - - public async Task IsConfiguredAsync() - => await settings.For().ExistsAsync(allProperties: true); + await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); } -``` - -| Method | Description | -|---|---| -| `GetAsync(refreshCache, ct)` | Returns the full settings object | -| `GetAsync(selector, refreshCache, ct)` | Returns one property | -| `SetAsync(model, ct)` | Persists the full object — validates, encrypts, audits, notifies | -| `SetAsync(selector, value, ct)` | Updates a single property | -| `ExistsAsync(allProperties, ct)` | Checks row existence | -| `ClearAsync(ct)` | Deletes all settings for the group | -| `GetVersionAsync(ct)` | Current revision, for conditional writes | -| `SetAsync(model, expectedVersion, ct)` | Persists only if the group is still at that revision | - -> **There is no synchronous read.** A blocking `Get()` would park a thread-pool thread on -> database I/O, and under load that starves the pool for the whole application — not just for -> settings. Where a value is needed inside a synchronous lambda, read it once with `await` -> beforehand and capture it; that is both correct and cheaper than resolving it per element. - ---- - -## Caching -Reads go through `ISettingCache` (default: `IMemoryCache`, 10-minute absolute expiration). A per-group `SemaphoreSlim` prevents cache stampedes under concurrent load. +app.MapSettingsEndpoints(); // adds GET/POST /api/settings/mail-server -```csharp -builder.Services.AddSettings() - .UseEntityFrameworkCore() - .WithCacheDuration(TimeSpan.FromMinutes(5)) - .UseCustomCache() // swap in a distributed cache - .Build(); -``` - -> **Multi-node note:** the default cache is per-instance in-memory. In a multi-node deployment, use `UseCustomCache()` 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 +app.MapGet("/", async (ISettingManager settings) => { - private readonly IDatabase _db = redis.GetDatabase(); - - public bool TryGetValue(string key, out T? value) - { - var raw = _db.StringGet(key); - if (!raw.HasValue) { value = default; return false; } - value = JsonSerializer.Deserialize(raw!); - return value is not null; - } - - public void Set(string key, T value, TimeSpan duration) - => _db.StringSet(key, JsonSerializer.Serialize(value), duration); - - public void Remove(string key) => _db.KeyDelete(key); -} -``` - ---- - -## Encryption for Sensitive Values - -Mark any property `[Sensitive]` and it is transparently encrypted before storage and decrypted on read — API keys, passwords, connection strings never touch the database as plaintext. + var mail = await settings.For().GetAsync(); + return $"Sending mail through {mail.Host}:{mail.Port}"; +}); -This is encryption **at rest**. It protects a database dump, a backup, or anyone with table access. It is not an access control: your application reads these values decrypted, and so does the REST API if you expose it — see [Securing the endpoints](#securing-the-endpoints). +app.Run(); -```csharp +// Your settings: a plain class. The property initialisers are the defaults. [SettingGroup("mail-server")] public class MailSettings { public string Host { get; set; } = "smtp.example.com"; - - [Sensitive] - public string Password { get; set; } = string.Empty; + public int Port { get; set; } = 587; } -``` -```csharp -// Built-in AES-256-GCM encryptor -var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); // store this in a secret manager! - -builder.Services.AddSettings() - .UseEntityFrameworkCore() - .UseAesEncryption(key) - .Build(); -``` - -Or plug in your own (Azure Key Vault, AWS KMS, etc.): - -```csharp -public class KeyVaultEncryptor(SecretClient client) : ISettingEncryptor +// Your EF Core context: add the two settings tables to it. +public class AppDbContext(DbContextOptions options) + : DbContext(options), ISettingDbContext { - public string Encrypt(string plaintext) { /* ... */ } - public string Decrypt(string ciphertext) { /* ... */ } -} + public DbSet Settings => Set(); + public DbSet SettingAudits => Set(); -builder.Services.AddSettings() - .UseCustomEncryption() - .Build(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplySettingsConfiguration(DatabaseProvider.Sqlite); +} ``` -> **Never hardcode the AES key.** Load it from an environment variable, Azure Key Vault, AWS Secrets Manager, or similar — the sample app generates a throwaway key at startup purely for demonstration. +**3. Run it and change a setting live** -### Rotating the encryption key - -Each encrypted value is stored as `v1:{keyId}:{base64}`, where `keyId` is a short fingerprint of the key that wrote it. That is what makes rotation safe: a value can be traced back to its key instead of just failing to authenticate. - -Pass the new key first and keep the old one as a retired key — retired keys decrypt, they never encrypt: - -```csharp -builder.Services.AddSettings() - .UseEntityFrameworkCore() - .UseAesEncryption(newKey, oldKey) // reads use either; writes use newKey - .Build(); +```bash +dotnet run --urls http://localhost:5080 ``` -Deploy that, then rewrite each group once (any `SetAsync` will do — including a save from the dashboard or `POST /api/settings/{route}`). Every rewritten group is re-encrypted under the new key. Once all of them have been rewritten, drop `oldKey`: +In a second terminal: -```csharp - .UseAesEncryption(newKey) +```bash +curl http://localhost:5080/ ``` -Values written before key ids existed are still readable: they carry no `v1:` prefix, so each configured key is tried in turn. AES-GCM authenticates, so a wrong key fails cleanly rather than returning garbage. - -> **A value that cannot be decrypted throws `SettingDecryptionException`.** This is deliberate — the alternative is a settings model whose secrets silently hold their compile-time defaults, so a mishandled rotation would leave the application running on default credentials instead of failing. `IgnoreDecryptionFailures()` restores the fall-back-to-default behaviour if you genuinely want it. - ---- - -## 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 logger) - : ISettingChangedHandler -{ - 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; - } -} +```text +Sending mail through smtp.example.com:587 ``` -```csharp -builder.Services.AddScoped(); -builder.Services.AddSettings() - .UseEntityFrameworkCore() - .OnChanged() - .Build(); +```bash +curl -X POST http://localhost:5080/api/settings/mail-server -H "Content-Type: application/json" -d '{"host":"smtp.mycompany.com","port":2525}' ``` -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. - ---- - -## 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() - .UseAuditStore() // ships with the EF Core package - .Build(); +```bash +curl http://localhost:5080/ ``` -Query history directly: - -```csharp -IReadOnlyList history = - await auditStore.GetHistoryAsync("MailSettings", key: "Host"); +```text +Sending mail through smtp.mycompany.com:2525 ``` -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. +The app picked up the new value without restarting, and it is saved in `settings.db`. -Write your own store (SQL table, Elasticsearch, whatever) by implementing `ISettingAuditStore`. +> ⚠️ **The settings endpoints allow anonymous access by default.** Before you deploy, protect them with +> `[Authorize]` on the settings class. See [Securing the endpoints](https://github.com/dotnetboost/settings/blob/main/docs/rest-api.md#securing-the-endpoints). --- -## Validation +## Using it in your own code -Validation is now enforced on **every write path** — both `SetAsync()` calls from your code and `POST` requests to the REST API — not just the API as before. - -### Data Annotations +Inject `ISettingManager` anywhere and read or write through `For()`: ```csharp -[SettingGroup("mail-server")] -public class MailSettings -{ - [Required, MaxLength(255)] - public string Host { get; set; } = string.Empty; - - [Range(1, 65535)] - public int Port { get; set; } = 587; -} -``` - -### FluentValidation - -```csharp -public class MailSettingsValidator : AbstractValidator +public class EmailService(ISettingManager settings) { - public MailSettingsValidator() + public async Task SendAsync() { - RuleFor(x => x.Host).NotEmpty().MaximumLength(255); - RuleFor(x => x.Port).InclusiveBetween(1, 65535); + var mail = await settings.For().GetAsync(); // whole object + var port = await settings.For().GetAsync(x => x.Port); // one property + // ... } -} -``` -```csharp -builder.Services.AddSettings() - .UseEntityFrameworkCore() - .UseFluentValidation(Assembly.GetExecutingAssembly()) - .Build(); + public Task ChangePortAsync(int port) + => settings.For().SetAsync(x => x.Port, port); +} ``` -A failed `SetAsync()` throws `SettingValidationException` with an `Errors` dictionary. A failed `POST` returns HTTP `400` with `ValidationProblemDetails`. +Reads are cached (10 minutes by default), so reading settings on every request is cheap. A write +clears the cache on that server straight away. If you run several servers, see [Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md). --- -## REST API Endpoints - -```bash -dotnet add package DotNetBoost.Settings.API -``` - -```csharp -app.MapSettingsEndpoints(); -``` +## Using your existing database -Registers, per `[SettingGroup]` class: +The quick start uses EF Core with SQLite because that needs no setup. In a real app, use the database you +already have: -| Method | Route | Description | +| You use… | Package | Register with | |---|---|---| -| `GET` | `/api/settings/{route}` | Current values, with an `ETag` for the revision | -| `POST` | `/api/settings/{route}` | Validate + persist; honours `If-Match`, `412` on a lost race | -| `GET` | `/api/settings/{route}/audit` | Change history (`404` if no audit store configured) | - -### Securing the endpoints - -**The generated endpoints are anonymous by default.** The library deliberately does not impose an -authorization policy — who may read and write your settings is your application's decision, not -this package's. It gives you the mechanism; you choose the policy. +| Entity Framework Core | `DotNetBoost.Settings.EntityFrameworkCore` | `.UseEntityFrameworkCore()` | +| Dapper / plain ADO.NET | `DotNetBoost.Settings.Dapper` | `.UseDapper(sp => new SqlConnection(cs), migrateSchema: true)` | +| MongoDB | `DotNetBoost.Settings.MongoDb` | `.UseMongoDb("mongodb://localhost:27017", "my_app_db")` | -Apply `[Authorize]` to the settings class and it covers all three endpoints for that group: - -```csharp -[SettingGroup("payment", Name = "PaymentSettings")] -[Authorize(Roles = "Admin")] // or [Authorize(Policy = "SettingsAdmin")] -public class PaymentSettings -{ - [Sensitive] - public string ApiKey { get; set; } = string.Empty; -} -``` - -> **Do this before exposing a group that holds `[Sensitive]` properties.** -> `GET` returns the settings object as your application sees it, which means secrets come back -> **decrypted** — that is what makes an editable admin UI possible. `[Sensitive]` encrypts values -> *at rest*: it protects a database dump, a backup, or a DBA with table access. It does not -> protect the API, which holds the key and decrypts on read. Without `[Authorize]`, a single -> unauthenticated `GET` returns every secret in the group in plaintext. - -Standard ASP.NET Core authorization applies, so anything that works elsewhere works here — -roles, policies, schemes: - -```csharp -[Authorize(AuthenticationSchemes = "Bearer", Policy = "SettingsAdmin")] -``` - -Remember that authorization is per class. Adding a new `[SettingGroup]` starts it anonymous, so -the attribute is worth adding at the same time as the class rather than afterwards. - -> The [`samples/SampleApp`](samples/SampleApp) settings classes carry no `[Authorize]` on -> purpose — the sample is a showcase of the library's features and runs without an identity -> provider. Do not copy that part into a real application. +EF Core needs two extra `DbSet`s on your context. See the +[storage providers guide](https://github.com/dotnetboost/settings/blob/main/docs/storage-providers.md) +for the full setup of each provider. --- -## Dashboard (SPA client) +## What else it can do -[`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. +Each feature is optional and switched on with one line on `AddSettings()`: -```bash -dotnet run --project samples/SampleApp --urls http://localhost:5199 # the API -npm install --prefix clients/dashboard && npm run dev --prefix clients/dashboard +```csharp +builder.Services.AddSettings() + .UseEntityFrameworkCore() + .UseAesEncryption(key) // encrypt [Sensitive] properties + .UseFluentValidation(typeof(Program).Assembly) // reject invalid values + .UseAuditStore() // keep a change history (EF Core) + .OnChanged() // react when a value changes + .Build(); ``` -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](#running-everything-with-net-aspire). - -### 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 | +| I want to… | Guide | |---|---| -| `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. +| Encrypt passwords and API keys in the database | [Encryption](https://github.com/dotnetboost/settings/blob/main/docs/encryption.md) | +| Reject invalid values (`[Range]`, FluentValidation) | [Validation](https://github.com/dotnetboost/settings/blob/main/docs/validation.md) | +| Run code when a setting changes | [Change notifications](https://github.com/dotnetboost/settings/blob/main/docs/change-notifications.md) | +| See who changed what, and when | [Audit trail](https://github.com/dotnetboost/settings/blob/main/docs/audit-trail.md) | +| Expose and secure the REST API | [REST API](https://github.com/dotnetboost/settings/blob/main/docs/rest-api.md) | +| Run on several servers (Redis cache) | [Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md) | +| Stop two people overwriting each other's edits | [Reading & writing](https://github.com/dotnetboost/settings/blob/main/docs/reading-and-writing.md#concurrent-writes) | +| Rename a settings class safely, set defaults | [Defining settings](https://github.com/dotnetboost/settings/blob/main/docs/defining-settings.md) | +| Give admins a web UI to edit settings | [Dashboard](https://github.com/dotnetboost/settings/blob/main/docs/dashboard.md) | +| See every builder option | [Configuration reference](https://github.com/dotnetboost/settings/blob/main/docs/configuration-reference.md) | --- -## Running everything with .NET Aspire +## Try the full demo -[`aspire/DotNetBoost.Settings.AppHost`](aspire/DotNetBoost.Settings.AppHost) orchestrates the whole -stack — API, dashboard and every backing service — from one command: +[`samples/SampleApp`](https://github.com/dotnetboost/settings/tree/main/samples/SampleApp) uses every +feature, with a web dashboard, PostgreSQL and Redis. If you have Docker running, one command starts all of it: ```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()`. 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. - -## Default Values - -```csharp -[SettingGroup("rate-limit")] -public class RateLimitSettings -{ - [SettingDefault(100)] - public int RequestsPerMinute { get; set; } -} -``` - -If no row exists in the store yet, `RequestsPerMinute` returns `100` instead of the CLR default `0` — useful for rolling out a new setting without a migration that back-fills every existing environment. - ---- - -## Architecture - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Your Application │ -│ ISettingManager.For() → ISettingAccessor │ -└───────────────────────────┬────────────────────────────────────┘ - │ - SettingManager - ┌───────────┬────────┼────────┬─────────────┐ - │ │ │ │ │ - ISettingCache ISettingStore ISettingEncryptor ISettingAuditStore - (IMemoryCache (EF Core / (AES-256-GCM (EfCoreAuditStore - or Redis) Dapper / or custom) or custom) - MongoDB) - │ - ISettingChangedHandler - (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 -``` - ---- - -## Configuration Reference - -### `AddSettings()` builder methods - -| Method | Package | Description | -|---|---|---| -| `.UseEntityFrameworkCore()` | 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()` | 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()` | Core | Plug in a custom encryptor | -| `.UseAuditStore()` | Core (+ EF Core impl) | Enable change history | -| `.OnChanged()` | Core | Register a runtime change handler | -| `.UseFluentValidation(assembly)` | FluentValidation | Register validators | -| `.Build()` | Core | Validate configuration, return `IServiceCollection` | +See [Running everything with .NET Aspire](https://github.com/dotnetboost/settings/blob/main/docs/aspire.md) for details. --- -## 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. +## Packages ---- +| Package | What it's for | +|---|---| +| `DotNetBoost.Settings.Core` | Required. The engine: `ISettingManager`, caching, encryption, audit, notifications | +| `DotNetBoost.Settings.EntityFrameworkCore` | Store settings through EF Core (SQL Server, PostgreSQL, SQLite) | +| `DotNetBoost.Settings.Dapper` | Store settings through Dapper (SQL Server, PostgreSQL, SQLite) | +| `DotNetBoost.Settings.MongoDb` | Store settings in MongoDB | +| `DotNetBoost.Settings.FluentValidation` | Validate settings with FluentValidation | +| `DotNetBoost.Settings.API` | The generated `/api/settings/...` REST endpoints | ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide — repo layout, coding standards, and how to add a new provider. +Contributions are welcome. See [CONTRIBUTING.md](https://github.com/dotnetboost/settings/blob/main/CONTRIBUTING.md) +and the [architecture overview](https://github.com/dotnetboost/settings/blob/main/docs/architecture.md). ## License -MIT — see [LICENSE](LICENSE). +MIT. See [LICENSE](https://github.com/dotnetboost/settings/blob/main/LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..986f44b --- /dev/null +++ b/docs/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1ec0906 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,67 @@ +# Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Your Application │ +│ ISettingManager.For() → ISettingAccessor │ +└───────────────────────────┬────────────────────────────────────┘ + │ + SettingManager + ┌───────────┬────────┼────────┬─────────────┐ + │ │ │ │ │ + ISettingCache ISettingStore ISettingEncryptor ISettingAuditStore + (IMemoryCache (EF Core / (AES-256-GCM (EfCoreAuditStore + or Redis) Dapper / or custom) or custom) + MongoDB) + │ + ISettingChangedHandler + (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. + diff --git a/docs/aspire.md b/docs/aspire.md new file mode 100644 index 0000000..d3f45a5 --- /dev/null +++ b/docs/aspire.md @@ -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()`. 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. + diff --git a/docs/audit-trail.md b/docs/audit-trail.md new file mode 100644 index 0000000..6398b99 --- /dev/null +++ b/docs/audit-trail.md @@ -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() + .UseAuditStore() // ships with the EF Core package + .Build(); +``` + +Query history directly: + +```csharp +IReadOnlyList 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`. + diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..60b1052 --- /dev/null +++ b/docs/caching.md @@ -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() + .WithCacheDuration(TimeSpan.FromMinutes(5)) + .UseCustomCache() // swap in a distributed cache + .Build(); +``` + +> **Multi-node note:** the default cache is per-instance in-memory. In a multi-node deployment, use `UseCustomCache()` 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(string key, out T? value) + { + var raw = _db.StringGet(key); + if (!raw.HasValue) { value = default; return false; } + value = JsonSerializer.Deserialize(raw!); + return value is not null; + } + + public void Set(string key, T value, TimeSpan duration) + => _db.StringSet(key, JsonSerializer.Serialize(value), duration); + + public void Remove(string key) => _db.KeyDelete(key); +} +``` + diff --git a/docs/change-notifications.md b/docs/change-notifications.md new file mode 100644 index 0000000..16b9a10 --- /dev/null +++ b/docs/change-notifications.md @@ -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 logger) + : ISettingChangedHandler +{ + 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(); +builder.Services.AddSettings() + .UseEntityFrameworkCore() + .OnChanged() + .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. + diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md new file mode 100644 index 0000000..3d95905 --- /dev/null +++ b/docs/configuration-reference.md @@ -0,0 +1,20 @@ +# Configuration reference + +## `AddSettings()` builder methods + +| Method | Package | Description | +|---|---|---| +| `.UseEntityFrameworkCore()` | 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()` | 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()` | Core | Plug in a custom encryptor | +| `.UseAuditStore()` | Core (+ EF Core impl) | Enable change history | +| `.OnChanged()` | Core | Register a runtime change handler | +| `.UseFluentValidation(assembly)` | FluentValidation | Register validators | +| `.Build()` | Core | Validate configuration, return `IServiceCollection` | + diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..1d09b43 --- /dev/null +++ b/docs/dashboard.md @@ -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. + diff --git a/docs/defining-settings.md b/docs/defining-settings.md new file mode 100644 index 0000000..30d21ec --- /dev/null +++ b/docs/defining-settings.md @@ -0,0 +1,74 @@ +# Defining settings + +```csharp +[SettingGroup("payment", Name = "PaymentSettings")] +public class PaymentSettings +{ + public string GatewayUrl { get; set; } = "https://gateway.example.com"; + + [Sensitive] // encrypted at rest + public string ApiKey { get; set; } = string.Empty; + + [SettingDefault(10_000)] // used when no row exists yet + public decimal MaxAmount { get; set; } = 10_000m; + + public bool SandboxMode { get; set; } = true; +} +``` + +**Rules:** +- Group name must be unique application-wide (see [Group names](#group-names) below). +- `[SettingGroup]` route value must be unique and non-empty. +- Violations throw `InvalidOperationException` at `Build()` time — fail fast at startup, not at 2am in production. + +## Group names + +`[SettingGroup]` carries two independent identifiers, and it is worth knowing which is which: + +| | What it controls | Safe to change? | +|---|---|---| +| `route` (positional) | The URL segment: `/api/settings/{route}` | Yes — it is a URL, no stored data depends on it | +| `Name` | The **storage key** every row for this group is written under | No — changing it strands the existing rows | + +`Name` is optional and **defaults to the class name**, which is the historical behaviour. Adding the attribute or upgrading the package never moves existing data. + +Setting it explicitly is recommended, because without it your database schema is silently coupled to a C# identifier: + +```csharp +[SettingGroup("mail-server", Name = "MailSettings")] +public class MailSettings { /* ... */ } +``` + +With `Name` pinned, the class can be renamed, moved to another namespace, or reorganised freely and it keeps reading the same rows. Without it, a rename that looks like pure refactoring silently orphans every stored value and the application quietly comes back up on defaults — including default credentials. + +### Migrating an existing group + +Adding `Name` to a class that **already has rows** requires renaming those rows, because you are changing the key they are stored under. Do it in the same deploy as the code change: + +```sql +UPDATE Settings SET SettingGroup = 'new-name' WHERE SettingGroup = 'OldClassName'; +UPDATE SettingAudits SET SettingGroup = 'new-name' WHERE SettingGroup = 'OldClassName'; +``` + +```js +// MongoDB +db.settings.updateMany({ Group: "OldClassName" }, { $set: { Group: "new-name" } }) +``` + +If you set `Name` to exactly the current class name — as the samples above do — there is nothing to migrate, and you gain the freedom to rename the class later. + +Two groups resolving to the same name would read and write each other's rows, so `Build()` rejects it at startup. That check compares resolved names, which means two same-named classes in different namespaces still collide unless one of them sets a distinct `Name`. + +## Default values + +```csharp +[SettingGroup("rate-limit")] +public class RateLimitSettings +{ + [SettingDefault(100)] + public int RequestsPerMinute { get; set; } +} +``` + +If no row exists in the store yet, `RequestsPerMinute` returns `100` instead of the CLR default `0` — useful for rolling out a new setting without a migration that back-fills every existing environment. + diff --git a/docs/encryption.md b/docs/encryption.md new file mode 100644 index 0000000..615f424 --- /dev/null +++ b/docs/encryption.md @@ -0,0 +1,66 @@ +# Encrypting sensitive values + +Mark any property `[Sensitive]` and it is transparently encrypted before storage and decrypted on read — API keys, passwords, connection strings never touch the database as plaintext. + +This is encryption **at rest**. It protects a database dump, a backup, or anyone with table access. It is not an access control: your application reads these values decrypted, and so does the REST API if you expose it — see [Securing the endpoints](rest-api.md#securing-the-endpoints). + +```csharp +[SettingGroup("mail-server")] +public class MailSettings +{ + public string Host { get; set; } = "smtp.example.com"; + + [Sensitive] + public string Password { get; set; } = string.Empty; +} +``` + +```csharp +// Built-in AES-256-GCM encryptor +var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); // store this in a secret manager! + +builder.Services.AddSettings() + .UseEntityFrameworkCore() + .UseAesEncryption(key) + .Build(); +``` + +Or plug in your own (Azure Key Vault, AWS KMS, etc.): + +```csharp +public class KeyVaultEncryptor(SecretClient client) : ISettingEncryptor +{ + public string Encrypt(string plaintext) { /* ... */ } + public string Decrypt(string ciphertext) { /* ... */ } +} + +builder.Services.AddSettings() + .UseCustomEncryption() + .Build(); +``` + +> **Never hardcode the AES key.** Load it from an environment variable, Azure Key Vault, AWS Secrets Manager, or similar — the sample app generates a throwaway key at startup purely for demonstration. + +## Rotating the encryption key + +Each encrypted value is stored as `v1:{keyId}:{base64}`, where `keyId` is a short fingerprint of the key that wrote it. That is what makes rotation safe: a value can be traced back to its key instead of just failing to authenticate. + +Pass the new key first and keep the old one as a retired key — retired keys decrypt, they never encrypt: + +```csharp +builder.Services.AddSettings() + .UseEntityFrameworkCore() + .UseAesEncryption(newKey, oldKey) // reads use either; writes use newKey + .Build(); +``` + +Deploy that, then rewrite each group once (any `SetAsync` will do — including a save from the dashboard or `POST /api/settings/{route}`). Every rewritten group is re-encrypted under the new key. Once all of them have been rewritten, drop `oldKey`: + +```csharp + .UseAesEncryption(newKey) +``` + +Values written before key ids existed are still readable: they carry no `v1:` prefix, so each configured key is tried in turn. AES-GCM authenticates, so a wrong key fails cleanly rather than returning garbage. + +> **A value that cannot be decrypted throws `SettingDecryptionException`.** This is deliberate — the alternative is a settings model whose secrets silently hold their compile-time defaults, so a mishandled rotation would leave the application running on default credentials instead of failing. `IgnoreDecryptionFailures()` restores the fall-back-to-default behaviour if you genuinely want it. + diff --git a/docs/reading-and-writing.md b/docs/reading-and-writing.md new file mode 100644 index 0000000..1272b2b --- /dev/null +++ b/docs/reading-and-writing.md @@ -0,0 +1,108 @@ +# Reading and writing settings + +```csharp +public class EmailService(ISettingManager settings) +{ + public async Task CreateClientAsync() + { + var mail = await settings.For().GetAsync(); + return new SmtpClient(mail.Host, mail.Port) { EnableSsl = mail.UseSsl }; + } + + public async Task GetPortAsync() + => await settings.For().GetAsync(x => x.Port); + + public async Task UpdatePortAsync(int port) + => await settings.For().SetAsync(x => x.Port, port); + + public async Task IsConfiguredAsync() + => await settings.For().ExistsAsync(allProperties: true); +} +``` + +| Method | Description | +|---|---| +| `GetAsync(refreshCache, ct)` | Returns the full settings object | +| `GetAsync(selector, refreshCache, ct)` | Returns one property | +| `SetAsync(model, ct)` | Persists the full object — validates, encrypts, audits, notifies | +| `SetAsync(selector, value, ct)` | Updates a single property | +| `ExistsAsync(allProperties, ct)` | Checks row existence | +| `ClearAsync(ct)` | Deletes all settings for the group | +| `GetVersionAsync(ct)` | Current revision, for conditional writes | +| `SetAsync(model, expectedVersion, ct)` | Persists only if the group is still at that revision | + +> **There is no synchronous read.** A blocking `Get()` would park a thread-pool thread on +> database I/O, and under load that starves the pool for the whole application — not just for +> settings. Where a value is needed inside a synchronous lambda, read it once with `await` +> beforehand and capture it; that is both correct and cheaper than resolving it per element. + +## Concurrent writes + +`SetAsync` writes **only the properties whose values differ from what is stored**, and each of +those writes is conditional on an optimistic concurrency token (`Setting.RowVersion`). Every +provider enforces it: SQL Server, PostgreSQL, SQLite and MongoDB. A write whose token no longer +matches throws `SettingConcurrencyException` rather than silently overwriting. + +```csharp +try +{ + await settings.For().SetAsync(model); +} +catch (SettingConcurrencyException ex) +{ + // ex.Group / ex.Key name the property that moved. Re-read, re-apply, retry. +} +``` + +### Across a read-edit-write cycle + +Per-row tokens cannot, on their own, protect an edit made *by your application* — a settings +POCO carries no record of the revision it was loaded at, so a stale copy of a field is +indistinguishable from a deliberate edit. The revision therefore has to travel out to the +caller and back. + +Over HTTP that is an entity tag, and the generated endpoints do it for you: + +```http +GET /api/settings/mail-server +200 OK +ETag: "9f2c1a7b3e5d0148" + +POST /api/settings/mail-server +If-Match: "9f2c1a7b3e5d0148" +→ 204 No Content if the group is still at that revision +→ 412 Precondition Failed if someone saved in between +``` + +A POST without `If-Match` writes unconditionally, so existing clients keep working. Once every +client round-trips the tag, make it mandatory — a POST without the header is then rejected with +`428 Precondition Required`: + +```csharp +app.MapSettingsEndpoints(requireIfMatch: true); +``` + +Programmatic callers get the same thing through the accessor: + +```csharp +var version = await settings.For().GetVersionAsync(); +var model = await settings.For().GetAsync(); +model.Host = "smtp.new.example.com"; +await settings.For().SetAsync(model, version); // throws if the group moved +``` + +> The check runs against the same snapshot the writes are built from, and the per-row tokens +> still guard the individual UPDATEs — so there is no window between checking the version and +> applying the change. + +The bundled dashboard does this already: it keeps the `ETag` from the load and sends it on +save. When the API refuses with `412` it does **not** discard your edits — it shows what +happened and offers two ways out: + +- **Re-apply my changes** — re-reads the group, keeps the other writer's edits to fields you did + not touch, replays only your own on top, and saves against the fresh revision. +- **Discard mine and reload** — throws your edits away and starts from the current values. + +Because the SPA reaches the API through its own Nitro proxy, the `ETag` is same-origin and +readable from JavaScript without `Access-Control-Expose-Headers`. + diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 0000000..c7753cc --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,57 @@ +# REST API endpoints + +```bash +dotnet add package DotNetBoost.Settings.API --prerelease +``` + +```csharp +app.MapSettingsEndpoints(); +``` + +Registers, per `[SettingGroup]` class: + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/settings/{route}` | Current values, with an `ETag` for the revision | +| `POST` | `/api/settings/{route}` | Validate + persist; honours `If-Match`, `412` on a lost race | +| `GET` | `/api/settings/{route}/audit` | Change history (`404` if no audit store configured) | + +## Securing the endpoints + +**The generated endpoints are anonymous by default.** The library deliberately does not impose an +authorization policy — who may read and write your settings is your application's decision, not +this package's. It gives you the mechanism; you choose the policy. + +Apply `[Authorize]` to the settings class and it covers all three endpoints for that group: + +```csharp +[SettingGroup("payment", Name = "PaymentSettings")] +[Authorize(Roles = "Admin")] // or [Authorize(Policy = "SettingsAdmin")] +public class PaymentSettings +{ + [Sensitive] + public string ApiKey { get; set; } = string.Empty; +} +``` + +> **Do this before exposing a group that holds `[Sensitive]` properties.** +> `GET` returns the settings object as your application sees it, which means secrets come back +> **decrypted** — that is what makes an editable admin UI possible. `[Sensitive]` encrypts values +> *at rest*: it protects a database dump, a backup, or a DBA with table access. It does not +> protect the API, which holds the key and decrypts on read. Without `[Authorize]`, a single +> unauthenticated `GET` returns every secret in the group in plaintext. + +Standard ASP.NET Core authorization applies, so anything that works elsewhere works here — +roles, policies, schemes: + +```csharp +[Authorize(AuthenticationSchemes = "Bearer", Policy = "SettingsAdmin")] +``` + +Remember that authorization is per class. Adding a new `[SettingGroup]` starts it anonymous, so +the attribute is worth adding at the same time as the class rather than afterwards. + +> The [`samples/SampleApp`](../samples/SampleApp) settings classes carry no `[Authorize]` on +> purpose — the sample is a showcase of the library's features and runs without an identity +> provider. Do not copy that part into a real application. + diff --git a/docs/storage-providers.md b/docs/storage-providers.md new file mode 100644 index 0000000..84ff42b --- /dev/null +++ b/docs/storage-providers.md @@ -0,0 +1,85 @@ +# Storage providers + +Pick one provider per application. Each is a separate package on top of `DotNetBoost.Settings.Core`. + +| Provider | Databases | Creates its own tables? | +|---|---|---| +| [Entity Framework Core](#entity-framework-core) | SQL Server, PostgreSQL, SQLite | No: through your EF migrations | +| [Dapper](#dapper) | SQL Server, PostgreSQL, SQLite | Yes, with `migrateSchema: true` | +| [MongoDB](#mongodb) | MongoDB | Yes (collections are created on demand) | + +## Entity Framework Core + +```bash +dotnet add package DotNetBoost.Settings.EntityFrameworkCore --prerelease +``` + +Add the two settings tables to your `DbContext`: + +```csharp +using DotNetBoost.Settings.Core.Models; +using DotNetBoost.Settings.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +public class AppDbContext(DbContextOptions options) + : DbContext(options), ISettingDbContext +{ + public DbSet Settings => Set(); + public DbSet SettingAudits => Set(); + + protected override void OnModelCreating(ModelBuilder mb) + => mb.ApplySettingsConfiguration(DatabaseProvider.Sqlite); + // Options: SqlServer | PostgreSql | Sqlite +} +``` + +```csharp +builder.Services.AddDbContext(o => o.UseSqlite("Data Source=app.db")); +builder.Services.AddSettings() + .UseEntityFrameworkCore() + .Build(); +``` + +Run `dotnet ef migrations add Init && dotnet ef database update` — this creates both the `Settings` and `SettingAudits` tables. + +## Dapper + +```bash +dotnet add package DotNetBoost.Settings.Dapper --prerelease +dotnet add package Microsoft.Data.SqlClient # or Npgsql / Microsoft.Data.Sqlite +``` + +```csharp +builder.Services.AddSettings() + .UseDapper(sp => new SqlConnection(connectionString), migrateSchema: true) + .Build(); +``` + +Supports `SqlConnection`, `NpgsqlConnection`, and `SqliteConnection`. `migrateSchema: true` auto-creates `Settings` and `SettingAudits` tables on startup. + +## MongoDB + +```bash +dotnet add package DotNetBoost.Settings.MongoDb --prerelease +``` + +```csharp +builder.Services.AddSettings() + .UseMongoDb("mongodb://localhost:27017", "my_app_db") + .Build(); +``` + +The unique `(Group, Key)` index the store relies on is created once at startup by a hosted service. Pass `createIndexes: false` if the application's MongoDB user has no index-creation rights or you manage the index out of band — the store still assumes it exists. + +**If your application already has a MongoDB client** — through .NET Aspire, or its own registration — pass a factory instead, so the settings store shares it rather than opening a second one: + +```csharp +builder.AddMongoDBClient("settingsdb"); // Aspire, or your own AddSingleton + +builder.Services.AddSettings() + .UseMongoDb(sp => sp.GetRequiredService().GetDatabase("my_app_db")) + .Build(); +``` + +Either overload keeps `IMongoClient` and `IMongoDatabase` out of the container: the provider holds its database privately, so it can neither override nor be overridden by your application's own Mongo registration — whichever order they happen in. + diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..102f91d --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,75 @@ +# Validation + +There are two ways to validate settings. They differ in which write paths they cover: + +| | Package | `POST` to the REST API | `SetAsync()` from your code | +|---|---|---|---| +| [FluentValidation](#fluentvalidation) | `DotNetBoost.Settings.FluentValidation` | ✅ | ✅ | +| [Data Annotations](#data-annotations) | none (built in) | ✅ | ❌ not checked | + +Use FluentValidation if your own code writes settings and you want those writes validated too. + +## FluentValidation + +**1. Install the package** + +```bash +dotnet add package DotNetBoost.Settings.FluentValidation --prerelease +``` + +It brings in `FluentValidation` itself, so you don't need to install that separately. + +**2. Write a validator for your settings class** + +```csharp +using FluentValidation; + +public class MailSettingsValidator : AbstractValidator +{ + public MailSettingsValidator() + { + RuleFor(x => x.Host).NotEmpty().MaximumLength(255); + RuleFor(x => x.Port).InclusiveBetween(1, 65535); + } +} +``` + +**3. Register it** + +`UseFluentValidation` finds every validator in the assembly you pass: + +```csharp +builder.Services.AddSettings() + .UseEntityFrameworkCore() + .UseFluentValidation(typeof(Program).Assembly) + .Build(); +``` + +When a value is invalid: + +- `SetAsync()` throws `SettingValidationException`. Its `Errors` dictionary maps each property name to its messages. Nothing is written. +- `POST /api/settings/{route}` returns HTTP `400` with `ValidationProblemDetails`. Nothing is written. + +## Data Annotations + +No extra package is needed. Add the attributes to your settings class: + +```csharp +using System.ComponentModel.DataAnnotations; + +[SettingGroup("mail-server")] +public class MailSettings +{ + [Required, MaxLength(255)] + public string Host { get; set; } = string.Empty; + + [Range(1, 65535)] + public int Port { get; set; } = 587; +} +``` + +An invalid `POST /api/settings/{route}` returns HTTP `400` with `ValidationProblemDetails`. + +> **Data Annotations are only checked by the REST API.** A `SetAsync()` call from your own code +> saves the value without checking these attributes. Also, if a FluentValidation validator exists +> for the same class, the API uses it and ignores the attributes.