CommunityToolkit / CommunityToolkit/dotnet

WritableOptionsMonitor

Open
#268 1 comment 0 reactions 0 assignees View on GitHub
feature request :mailbox_with_mail:
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.