Skip to content

Commit 490a820

Browse files
committed
feat: extend support for Post-Configuration OptionsBinding
1 parent 874a958 commit 490a820

File tree

17 files changed

+1049
-15
lines changed

17 files changed

+1049
-15
lines changed

CLAUDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ services.AddDependencyRegistrationsFromDomain(
299299
5. Auto-inferred from class name
300300
- Supports validation: `ValidateDataAnnotations`, `ValidateOnStart`, `ErrorOnMissingKeys` (fail-fast for missing sections), Custom validators (`IValidateOptions<T>`)
301301
- **Configuration change callbacks**: Auto-generated IHostedService for OnChange notifications with Monitor lifetime - perfect for feature flags and runtime configuration updates
302+
- **Post-configuration support**: `PostConfigure` callbacks for normalizing/transforming values after binding (e.g., path normalization, URL lowercase)
302303
- **Named options support**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
303304
- **Nested subsection binding**: Automatic binding of complex properties to configuration subsections (e.g., `StorageOptions.Database.Retry``"Storage:Database:Retry"`) - supported out-of-the-box by Microsoft's `.Bind()` method
304305
- Supports lifetime selection: Singleton (IOptions), Scoped (IOptionsSnapshot), Monitor (IOptionsMonitor)
@@ -404,6 +405,30 @@ services.AddSingleton<IOptionsChangeTokenSource<FeaturesOptions>>(
404405
new ConfigurationChangeTokenSource<FeaturesOptions>(
405406
configuration.GetSection("Features")));
406407
services.Configure<FeaturesOptions>(configuration.GetSection("Features"));
408+
409+
// Input with PostConfigure (path normalization):
410+
[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))]
411+
public partial class StorageOptions
412+
{
413+
public string BasePath { get; set; } = string.Empty;
414+
public string CachePath { get; set; } = string.Empty;
415+
416+
private static void NormalizePaths(StorageOptions options)
417+
{
418+
options.BasePath = EnsureTrailingSlash(options.BasePath);
419+
options.CachePath = EnsureTrailingSlash(options.CachePath);
420+
}
421+
422+
private static string EnsureTrailingSlash(string path)
423+
=> string.IsNullOrWhiteSpace(path) || path.EndsWith(Path.DirectorySeparatorChar)
424+
? path
425+
: path + Path.DirectorySeparatorChar;
426+
}
427+
428+
// Output with PostConfigure:
429+
services.AddOptions<StorageOptions>()
430+
.Bind(configuration.GetSection("Storage"))
431+
.PostConfigure(options => StorageOptions.NormalizePaths(options));
407432
```
408433

409434
**Smart Naming:**
@@ -438,6 +463,9 @@ services.AddOptionsFromDomain(configuration, "DataAccess", "Infrastructure");
438463
- `ATCOPT005` - OnChange not supported with named options (Error)
439464
- `ATCOPT006` - OnChange callback method not found (Error)
440465
- `ATCOPT007` - OnChange callback has invalid signature (Error)
466+
- `ATCOPT008` - PostConfigure not supported with named options (Error)
467+
- `ATCOPT009` - PostConfigure callback method not found (Error)
468+
- `ATCOPT010` - PostConfigure callback has invalid signature (Error)
441469

442470
### MappingGenerator
443471

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ services.AddOptionsFromApp(configuration);
320320
- **🔒 Built-in Validation**: Integrated DataAnnotations validation (`ValidateDataAnnotations`) and startup validation (`ValidateOnStart`)
321321
- **🎯 Custom Validation**: Support for `IValidateOptions<T>` for complex business rules beyond DataAnnotations
322322
- **🔔 Configuration Change Callbacks**: Auto-generated IHostedService for OnChange notifications with Monitor lifetime - perfect for feature flags and runtime config updates
323+
- **🔧 Post-Configuration Support**: Normalize or transform values after binding with `PostConfigure` callbacks (e.g., ensure paths have trailing slashes, lowercase URLs)
323324
- **📛 Named Options**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
324325
- **🎯 Explicit Section Paths**: Support for nested sections like `"App:Database"` or `"Services:Email"`
325326
- **📂 Nested Subsection Binding**: Automatic binding of complex properties to configuration subsections (e.g., `StorageOptions.Database.Retry``"Storage:Database:Retry"`)
@@ -422,6 +423,9 @@ public class MyService
422423
| ATCOPT005 | OnChange not supported with named options |
423424
| ATCOPT006 | OnChange callback method not found |
424425
| ATCOPT007 | OnChange callback has invalid signature |
426+
| ATCOPT008 | PostConfigure not supported with named options |
427+
| ATCOPT009 | PostConfigure callback method not found |
428+
| ATCOPT010 | PostConfigure callback has invalid signature |
425429

426430
---
427431

docs/OptionsBindingGenerators-FeatureRoadmap.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,14 @@ This roadmap is based on comprehensive analysis of:
5858
- **Named options** - Multiple configurations of the same options type with different names
5959
- **Error on missing keys** - `ErrorOnMissingKeys` fail-fast validation when configuration sections are missing
6060
- **Configuration change callbacks** - `OnChange` callbacks for Monitor lifetime (auto-generates IHostedService)
61+
- **Post-configuration support** - `PostConfigure` callbacks for normalizing/transforming values after binding (e.g., path normalization, URL lowercase)
6162
- **Nested subsection binding** - Automatic binding of complex properties to configuration subsections (e.g., `Storage:Database:Retry`)
6263
- **Lifetime selection** - Singleton (`IOptions`), Scoped (`IOptionsSnapshot`), Monitor (`IOptionsMonitor`)
6364
- **Multi-project support** - Assembly-specific extension methods with smart naming
6465
- **Transitive registration** - 4 overloads for automatic/selective assembly registration
6566
- **Partial class requirement** - Enforced at compile time
6667
- **Native AOT compatible** - Zero reflection, compile-time generation
67-
- **Compile-time diagnostics** - Validate partial class, section names, OnChange callbacks (ATCOPT001-007)
68+
- **Compile-time diagnostics** - Validate partial class, section names, OnChange/PostConfigure callbacks (ATCOPT001-010)
6869

6970
---
7071

@@ -74,7 +75,7 @@ This roadmap is based on comprehensive analysis of:
7475
|:------:|---------|----------|
7576
|| [Custom Validation Support (IValidateOptions)](#1-custom-validation-support-ivalidateoptions) | 🔴 High |
7677
|| [Named Options Support](#2-named-options-support) | 🔴 High |
77-
| | [Post-Configuration Support](#3-post-configuration-support) | 🟡 Medium-High |
78+
| | [Post-Configuration Support](#3-post-configuration-support) | 🟡 Medium-High |
7879
|| [Error on Missing Configuration Keys](#4-error-on-missing-configuration-keys) | 🔴 High |
7980
|| [Configuration Change Callbacks](#5-configuration-change-callbacks) | 🟡 Medium |
8081
|| [Bind Configuration Subsections to Properties](#6-bind-configuration-subsections-to-properties) | 🟡 Medium |
@@ -249,7 +250,7 @@ public class DataService
249250
### 3. Post-Configuration Support
250251

251252
**Priority**: 🟡 **Medium-High**
252-
**Status**: ❌ Not Implemented
253+
**Status**: **Implemented**
253254
**Inspiration**: `IPostConfigureOptions<T>` pattern
254255

255256
**Description**: Support post-configuration actions that run after binding and validation to apply defaults or transformations.
@@ -288,12 +289,28 @@ services.AddOptions<StorageOptions>()
288289
.PostConfigure(options => StorageOptions.NormalizePaths(options));
289290
```
290291

291-
**Implementation Notes**:
292+
**Implementation Details**:
293+
294+
- ✅ Added `PostConfigure` property to `[OptionsBinding]` attribute
295+
- ✅ Generator calls `.PostConfigure()` method on the options builder
296+
- ✅ PostConfigure method must have signature: `static void MethodName(TOptions options)`
297+
- ✅ Runs after binding and validation
298+
- ✅ PostConfigure method can be `internal` or `public` (not `private`)
299+
- ⚠️ Cannot be used with named options
300+
- ✅ Comprehensive compile-time validation with 3 diagnostic codes (ATCOPT008-010)
301+
- ✅ Useful for normalization, defaults, computed properties
302+
303+
**Diagnostics**:
304+
305+
- **ATCOPT008**: PostConfigure callback not supported with named options
306+
- **ATCOPT009**: PostConfigure callback method not found
307+
- **ATCOPT010**: PostConfigure callback method has invalid signature
308+
309+
**Testing**:
292310

293-
- Add `PostConfigure` parameter pointing to static method
294-
- Method signature: `static void Configure(TOptions options)`
295-
- Runs after binding and validation
296-
- Useful for normalization, defaults, computed properties
311+
- ✅ Unit tests covering all scenarios and error cases
312+
- ✅ Sample project: StoragePathsOptions demonstrates path normalization (trailing slash)
313+
- ✅ PetStore.Api sample: ExternalApiOptions demonstrates URL normalization (lowercase + trailing slash removal)
297314

298315
---
299316

docs/OptionsBindingGenerators.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@ Console.WriteLine($"Other interval: {otherOptions.Value.RepeatIntervalInSeconds}
745745
- **🎯 Custom validation** - Support for `IValidateOptions<T>` for complex business rules beyond DataAnnotations
746746
- **🚨 Error on missing keys** - Fail-fast validation when configuration sections are missing (`ErrorOnMissingKeys`) to catch deployment issues at startup
747747
- **🔔 Configuration change callbacks** - Automatically respond to configuration changes at runtime with `OnChange` callbacks (requires Monitor lifetime)
748+
- **🔧 Post-configuration support** - Normalize or transform values after binding with `PostConfigure` callbacks (e.g., ensure paths have trailing slashes, lowercase URLs)
748749
- **📛 Named options** - Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
749750
- **🎯 Explicit section paths** - Support for nested sections like `"App:Database"` or `"Services:Email"`
750751
- **📂 Nested subsection binding** - Automatically bind complex properties to configuration subsections (e.g., `StorageOptions.Database.Retry``"Storage:Database:Retry"`)
@@ -1324,6 +1325,159 @@ The generator performs compile-time validation of OnChange callbacks:
13241325

13251326
---
13261327

1328+
### 🔧 Post-Configuration Support
1329+
1330+
Automatically normalize, validate, or transform configuration values after binding using the `PostConfigure` property. This feature enables applying defaults, normalizing paths, lowercasing URLs, or computing derived properties.
1331+
1332+
**Requirements:**
1333+
- Cannot be used with named options
1334+
- Callback method must have signature: `static void MethodName(TOptions options)`
1335+
- Runs after binding and validation
1336+
1337+
**Basic Example:**
1338+
1339+
```csharp
1340+
[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))]
1341+
public partial class StoragePathsOptions
1342+
{
1343+
public string BasePath { get; set; } = string.Empty;
1344+
public string CachePath { get; set; } = string.Empty;
1345+
public string TempPath { get; set; } = string.Empty;
1346+
1347+
private static void NormalizePaths(StoragePathsOptions options)
1348+
{
1349+
// Ensure all paths end with directory separator
1350+
options.BasePath = EnsureTrailingSlash(options.BasePath);
1351+
options.CachePath = EnsureTrailingSlash(options.CachePath);
1352+
options.TempPath = EnsureTrailingSlash(options.TempPath);
1353+
}
1354+
1355+
private static string EnsureTrailingSlash(string path)
1356+
{
1357+
if (string.IsNullOrWhiteSpace(path))
1358+
return path;
1359+
1360+
return path.EndsWith(Path.DirectorySeparatorChar)
1361+
? path
1362+
: path + Path.DirectorySeparatorChar;
1363+
}
1364+
}
1365+
```
1366+
1367+
**Generated Code:**
1368+
1369+
The generator automatically calls `.PostConfigure()` after binding:
1370+
1371+
```csharp
1372+
services.AddOptions<StoragePathsOptions>()
1373+
.Bind(configuration.GetSection("Storage"))
1374+
.PostConfigure(options => StoragePathsOptions.NormalizePaths(options));
1375+
```
1376+
1377+
**Usage Scenarios:**
1378+
1379+
```csharp
1380+
// Path normalization - ensure trailing slashes
1381+
[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))]
1382+
public partial class StoragePathsOptions
1383+
{
1384+
public string BasePath { get; set; } = string.Empty;
1385+
public string CachePath { get; set; } = string.Empty;
1386+
1387+
private static void NormalizePaths(StoragePathsOptions options)
1388+
{
1389+
options.BasePath = EnsureTrailingSlash(options.BasePath);
1390+
options.CachePath = EnsureTrailingSlash(options.CachePath);
1391+
}
1392+
1393+
private static string EnsureTrailingSlash(string path)
1394+
=> string.IsNullOrWhiteSpace(path) || path.EndsWith(Path.DirectorySeparatorChar)
1395+
? path
1396+
: path + Path.DirectorySeparatorChar;
1397+
}
1398+
1399+
// URL normalization - lowercase and remove trailing slashes
1400+
[OptionsBinding("ExternalApi", PostConfigure = nameof(NormalizeUrls))]
1401+
public partial class ExternalApiOptions
1402+
{
1403+
public string BaseUrl { get; set; } = string.Empty;
1404+
public string CallbackUrl { get; set; } = string.Empty;
1405+
1406+
private static void NormalizeUrls(ExternalApiOptions options)
1407+
{
1408+
options.BaseUrl = NormalizeUrl(options.BaseUrl);
1409+
options.CallbackUrl = NormalizeUrl(options.CallbackUrl);
1410+
}
1411+
1412+
private static string NormalizeUrl(string url)
1413+
{
1414+
if (string.IsNullOrWhiteSpace(url))
1415+
return url;
1416+
1417+
// Lowercase and remove trailing slash
1418+
return url.ToLowerInvariant().TrimEnd('/');
1419+
}
1420+
}
1421+
1422+
// Combined with validation
1423+
[OptionsBinding("Database",
1424+
ValidateDataAnnotations = true,
1425+
ValidateOnStart = true,
1426+
PostConfigure = nameof(ApplyDefaults))]
1427+
public partial class DatabaseOptions
1428+
{
1429+
[Required] public string ConnectionString { get; set; } = string.Empty;
1430+
public int CommandTimeout { get; set; }
1431+
1432+
private static void ApplyDefaults(DatabaseOptions options)
1433+
{
1434+
// Apply default timeout if not set
1435+
if (options.CommandTimeout <= 0)
1436+
{
1437+
options.CommandTimeout = 30;
1438+
}
1439+
}
1440+
}
1441+
```
1442+
1443+
**Validation Errors:**
1444+
1445+
The generator performs compile-time validation of PostConfigure callbacks:
1446+
1447+
- **ATCOPT008**: PostConfigure callback not supported with named options
1448+
```csharp
1449+
// Error: Named options don't support PostConfigure
1450+
[OptionsBinding("Email", Name = "Primary", PostConfigure = nameof(Normalize))]
1451+
public partial class EmailOptions { }
1452+
```
1453+
1454+
- **ATCOPT009**: PostConfigure callback method not found
1455+
```csharp
1456+
// Error: Method 'ApplyDefaults' does not exist
1457+
[OptionsBinding("Settings", PostConfigure = "ApplyDefaults")]
1458+
public partial class Settings { }
1459+
```
1460+
1461+
- **ATCOPT010**: PostConfigure callback method has invalid signature
1462+
```csharp
1463+
// Error: Must be static void with (TOptions) parameter
1464+
[OptionsBinding("Settings", PostConfigure = nameof(Configure))]
1465+
public partial class Settings
1466+
{
1467+
private void Configure() { } // Wrong: not static, missing parameter
1468+
}
1469+
```
1470+
1471+
**Important Notes:**
1472+
1473+
- PostConfigure runs **after** binding and validation
1474+
- Callback method can be `internal` or `public` (not `private`)
1475+
- Cannot be combined with named options (use manual `.PostConfigure()` if needed)
1476+
- Perfect for normalizing user input, applying business rules, or computed properties
1477+
- Order of execution: Bind → Validate → PostConfigure
1478+
1479+
---
1480+
13271481
## 🔧 How It Works
13281482

13291483
### 1️⃣ Attribute Detection
@@ -1888,6 +2042,14 @@ public partial class DatabaseOptions // ✅ Inferred as "Database"
18882042
}
18892043
```
18902044

2045+
### ❌ ATCOPT004-007: OnChange Callback Diagnostics
2046+
2047+
See [Configuration Change Callbacks](#-configuration-change-callbacks) section for details.
2048+
2049+
### ❌ ATCOPT008-010: PostConfigure Callback Diagnostics
2050+
2051+
See [Post-Configuration Support](#-post-configuration-support) section for details.
2052+
18912053
---
18922054

18932055
## 🚀 Native AOT Compatibility

sample/Atc.SourceGenerators.OptionsBinding/Options/CloudStorageOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,4 @@ public class RetryPolicy
8686
public int DelayMilliseconds { get; set; } = 1000;
8787

8888
public bool UseExponentialBackoff { get; set; } = true;
89-
}
89+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
namespace Atc.SourceGenerators.OptionsBinding.Options;
2+
3+
/// <summary>
4+
/// Storage paths configuration options.
5+
/// Demonstrates Feature #7: Post-Configuration support for normalizing values after binding.
6+
/// The PostConfigure callback ensures all paths end with a directory separator,
7+
/// providing consistent path handling across the application.
8+
/// </summary>
9+
[OptionsBinding("StoragePaths", ValidateDataAnnotations = true, PostConfigure = nameof(NormalizePaths))]
10+
public partial class StoragePathsOptions
11+
{
12+
[Required]
13+
public string BasePath { get; set; } = string.Empty;
14+
15+
[Required]
16+
public string CachePath { get; set; } = string.Empty;
17+
18+
[Required]
19+
public string TempPath { get; set; } = string.Empty;
20+
21+
public string LogPath { get; set; } = string.Empty;
22+
23+
/// <summary>
24+
/// Post-configuration method to normalize all path values.
25+
/// This ensures all paths end with a directory separator for consistent usage.
26+
/// Signature: static void MethodName(TOptions options)
27+
/// </summary>
28+
internal static void NormalizePaths(StoragePathsOptions options)
29+
{
30+
// Normalize all paths to ensure they end with directory separator
31+
options.BasePath = EnsureTrailingDirectorySeparator(options.BasePath);
32+
options.CachePath = EnsureTrailingDirectorySeparator(options.CachePath);
33+
options.TempPath = EnsureTrailingDirectorySeparator(options.TempPath);
34+
options.LogPath = EnsureTrailingDirectorySeparator(options.LogPath);
35+
}
36+
37+
/// <summary>
38+
/// Ensures a path ends with a directory separator character.
39+
/// </summary>
40+
private static string EnsureTrailingDirectorySeparator(string path)
41+
{
42+
if (string.IsNullOrWhiteSpace(path))
43+
{
44+
return path;
45+
}
46+
47+
return path.EndsWith(Path.DirectorySeparatorChar) ||
48+
path.EndsWith(Path.AltDirectorySeparatorChar)
49+
? path
50+
: path + Path.DirectorySeparatorChar;
51+
}
52+
}

0 commit comments

Comments
 (0)