CommunityToolkit / CommunityToolkit/dotnet
WritableOptionsMonitor
- Dominant language
- C#
- Stars
- 3.8k
- Forks
- 400
- PR merge metrics
- No merged PRs in 30d
Description
### Overview
[IConfiguration](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.iconfiguration) used with `appsettings.json` and IOptions pattern is quite common. The only thing I really miss is to be able to save the changes back to `appsettings.json`. `IOptionsMonitor` can read, but not able to write, and it would be awesome if there was `IWritableOptionsMonitor` extension that can do that.
### API breakdown
```csharp
///
/// Writable implementation of IOptionsMonitor.
///
/// Options model.
public partial class WritableOptionsMonitor : OptionsMonitor, IWritableOptionsMonitor where TOptions : class
{
private const string _baseFile = "appsettings.json";
private readonly IConfigurationRoot _configuration;
private readonly IConfigurationSection _configurationSection;
private readonly ILogger _logger;
private readonly string _appsettingsPhysicalPath;
private readonly JsonDocumentOptions _jsonDocumentOptions;
private readonly JsonWriterOptions _jsonWriterOptions;
#region Log
[LoggerMessage(0, LogLevel.Warning, "Couldn't write the settings. File path: {AppsettingsPhysicalPath}.")]
partial void LogWriteError(string appsettingsPhysicalPath, Exception exception);
#endregion
///
/// Constructor.
///
/// The factory to use to create options.
/// The sources used to listen for changes to the options instance.
/// The cache used to store options.
/// Hosting environment.
/// IConfiguration root.
/// Configuration section.
/// Logger.
public WritableOptionsMonitor(
IOptionsFactory factory,
IEnumerable> sources,
IOptionsMonitorCache cache,
IHostEnvironment hostEnvironment,
IConfigurationRoot configuration,
IConfigurationSection configurationSection,
ILogger logger) : base(factory, sources, cache)
{
Guard.IsNotNull(hostEnvironment, nameof(hostEnvironment));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_configurationSection = configurationSection ?? throw new ArgumentNullException(nameof(configurationSection));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_appsettingsPhysicalPath = GetAppSettingsPhysicalPath(_baseFile, hostEnvironment);
_jsonDocumentOptions = new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
};
_jsonWriterOptions = new JsonWriterOptions
{
Indented = true,
SkipValidation = false
};
}
///
/// Get the physical path of the appsettings.json.
/// If the appsettings.{Environment}.json exists, than it retuns that one.
///
/// The base settings file, not the environment specific one.
/// Host environment
/// Environment specific physical path of the settings file if exists, otherwise the physical path of the base settings file.
private static string GetAppSettingsPhysicalPath(string baseFile, IHostEnvironment hostEnvironment)
{
string environmentSpecificFileName = $"{Path.GetFileNameWithoutExtension(baseFile)}.{hostEnvironment.EnvironmentName}{Path.GetExtension(baseFile)}";
string appsettingsPhysicalPath = Path.Combine(hostEnvironment.ContentRootPath, environmentSpecificFileName);
if (!File.Exists(appsettingsPhysicalPath))
{
appsettingsPhysicalPath = Path.Combine(hostEnvironment.ContentRootPath, baseFile);
}
return appsettingsPhysicalPath;
}
///
/// Update the application settings file.
///
/// Action to make the modification in the configuration section.
/// true if success, otherwise false
public bool Update(Action applyChanges)
{
ReadOnlyMemory appsettingsMemory = File.ReadAllBytes(_appsettingsPhysicalPath);
JsonElement appsettingsRootElement;
using var appsettingsJsonDocument = JsonDocument.Parse(appsettingsMemory, _jsonDocumentOptions);
appsettingsRootElement = appsettingsJsonDocument.RootElement;
var optionObject = CurrentValue;
applyChanges(optionObject);
var updatedOptionJsonElement = JsonSerializer.SerializeToElement(optionObject);
try
{
using var fileStream = new FileStream(_appsettingsPhysicalPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
using var utf8JsonWriter = new Utf8JsonWriter(fileStream, options: _jsonWriterOptions);
WriteAppsSettingsJson(appsettingsRootElement, utf8JsonWriter, updatedOptionJsonElement);
utf8JsonWriter.Flush();
}
catch (Exception exception)
{
LogWriteError(_appsettingsPhysicalPath, exception);
return false;
}
_configuration.Reload();
return true;
}
///
/// Task if success, otherwise Task
public async Task UpdateAsync(Action applyChanges)
{
ReadOnlyMemory appsettingsMemory = await File.ReadAllBytesAsync(_appsettingsPhysicalPath);
using var appsettingsJsonDocument = JsonDocument.Parse(appsettingsMemory, _jsonDocumentOptions);
var appsettingsRootElement = appsettingsJsonDocument.RootElement;
var optionObject = CurrentValue;
applyChanges(optionObject);
var updatedOptionJsonElement = JsonSerializer.SerializeToElement(optionObject);
try
{
await using var fileStream = new FileStream(_appsettingsPhysicalPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
await using var utf8JsonWriter = new Utf8JsonWriter(fileStream, options: _jsonWriterOptions);
WriteAppsSettingsJson(appsettingsRootElement, utf8JsonWriter, updatedOptionJsonElement);
await utf8JsonWriter.FlushAsync();
}
catch (Exception exception)
{
LogWriteError(_appsettingsPhysicalPath, exception);
return false;
}
_configuration.Reload();
return true;
}
private void WriteAppsSettingsJson(in JsonElement appsettingsRootElement, Utf8JsonWriter utf8JsonWriter, in JsonElement updatedOptionJsonElement)
{
utf8JsonWriter.WriteStartObject();
bool propertyFound = false;
foreach (var property in appsettingsRootElement.EnumerateObject())
{
if (_configurationSection.Key.Equals(property.Name))
{
propertyFound = true;
utf8JsonWriter.WritePropertyName(_configurationSection.Key);
updatedOptionJsonElement.WriteTo(utf8JsonWriter);
}
else
{
property.WriteTo(utf8JsonWriter);
}
}
if (!propertyFound)
{
utf8JsonWriter.WritePropertyName(_configurationSection.Key);
updatedOptionJsonElement.WriteTo(utf8JsonWriter);
}
utf8JsonWriter.WriteEndObject();
}
}
```
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection ConfigureWritable(this IServiceCollection services, IConfigurationSection section) where TOptions : class
{
return services.AddSingleton>(serviceProvider =>
{
var optionsFactory = serviceProvider.GetRequiredService>();
var optionsChangeTokenSources = serviceProvider.GetRequiredService>>();
var optionsMonitorCache = serviceProvider.GetRequiredService>();
var hostEnvironment = serviceProvider.GetRequiredService();
var configurationRoot = (IConfigurationRoot)serviceProvider.GetRequiredService();
var loggerFactory = serviceProvider.GetRequiredService();
var logger = loggerFactory.CreateLogger();
return new WritableOptionsMonitor(optionsFactory, optionsChangeTokenSources, optionsMonitorCache, hostEnvironment, configurationRoot, section, logger);
});
}
}
```
### Usage example
```csharp
using SampleApp.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.ConfigureWritable(builder.Configuration.GetSection("MyOptions"));
var app = builder.Build();
```
```csharp
public class TestMonitorModel : PageModel
{
private readonly IWritableOptionsMonitor _optionsDelegate;
public TestMonitorModel(IWritableOptionsMonitor optionsDelegate)
{
_optionsDelegate = optionsDelegate;
}
public ContentResult OnGet()
{
return Content($"Option1: {_optionsDelegate.CurrentValue.Option1} \n" +
$"Option2: {_optionsDelegate.CurrentValue.Option2}");
}
public IActionResult Change(string value)
{
_optionsDelegate.Update(opt =>
{
opt.Name = value;
});
return RedirectToAction("Index");
}
}
```
### Breaking change?
No
### Alternatives
I can't come up any other alternative yet.
### Additional context
_No response_
### Help us help you
Yes, but only if others can assist
Contributor guide
Assessment
This issue has not been assessed yet.