Auto stash before merge of "main" and "origin/main"

This commit is contained in:
Janus C. H. Knudsen 2025-02-02 23:13:17 +01:00
parent 21d7128e74
commit 521190475d
41 changed files with 991 additions and 1150 deletions

View file

@ -1,30 +1,29 @@
using Autofac; using Autofac;
using Core.Configurations.JsonConfigProvider;
using Core.Configurations;
namespace PlanTempus namespace PlanTempus
{ {
public class Startup public class Startup
{ {
public Startup(IWebHostEnvironment env) public Startup(IWebHostEnvironment env)
{ {
var builder = new ConfigurationBuilder() var builder = new Core.Configurations.ConfigurationBuilder()
.SetBasePath(env.ContentRootPath) .AddJsonFile("appconfiguration.json", optional: true, reloadOnChange: true);
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables();
ConfigurationRoot = builder.Build(); ConfigurationRoot = builder.Build();
} }
public IConfigurationRoot ConfigurationRoot { get; private set; } public Core.Configurations.IConfigurationRoot ConfigurationRoot { get; private set; }
public ILifetimeScope AutofacContainer { get; private set; } public ILifetimeScope AutofacContainer { get; private set; }
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.AddControllers(); services.AddControllers();
services.AddOptions(); services.AddOptions();
services.AddRazorPages(); services.AddRazorPages();
//services.AddApplicationInsightsTelemetry(ConfigurationRoot["ApplicationInsights:InstrumentationKey"]); //services.AddApplicationInsightsTelemetry(ConfigurationRoot["ApplicationInsights:InstrumentationKey"]);
services.AddAntiforgery(x => x.HeaderName = "X-ANTI-FORGERY-TOKEN"); services.AddAntiforgery(x => x.HeaderName = "X-ANTI-FORGERY-TOKEN");
services.Configure<Microsoft.AspNetCore.Mvc.Razor.RazorViewEngineOptions>(options => services.Configure<Microsoft.AspNetCore.Mvc.Razor.RazorViewEngineOptions>(options =>
{ {
@ -32,42 +31,43 @@ namespace PlanTempus
}); });
} }
public void ConfigureContainer(ContainerBuilder builder) public void ConfigureContainer(ContainerBuilder builder)
{ {
builder.RegisterModule(new Core.ModuleRegistry.DbPostgreSqlModule builder.RegisterModule(new Core.ModuleRegistry.DbPostgreSqlModule
{ {
ConnectionString = ConfigurationRoot.GetConnectionString("ptdb") ConnectionString = ConfigurationRoot.GetConnectionString("ptdb")
}); });
builder.RegisterModule(new Core.ModuleRegistry.TelemetryModule
{
TelemetryConfig = ConfigurationRoot.GetSection(nameof(Core.ModuleRegistry.TelemetryConfig)).Get<Core.ModuleRegistry.TelemetryConfig>()
});
builder.RegisterModule(new Core.ModuleRegistry.TelemetryModule
{
TelemetryConfig = ConfigurationRoot.GetSection(nameof(Core.ModuleRegistry.TelemetryConfig)).Get<Core.ModuleRegistry.TelemetryConfig>()
});
} }
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{ {
if (env.IsDevelopment()) if (env.IsDevelopment())
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
else else
{ {
app.UseExceptionHandler("/Error"); app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts(); app.UseHsts();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRouting(); app.UseRouting();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {
//endpoints.MapHub<MotionHub>("/motionhub"); //endpoints.MapHub<MotionHub>("/motionhub");
endpoints.MapRazorPages(); endpoints.MapRazorPages();
endpoints.MapControllers(); endpoints.MapControllers();
}); });
} }
} }
} }

View file

@ -5,5 +5,10 @@
}, },
"TelemetryConfig": { "TelemetryConfig": {
"ConnectionString": "InstrumentationKey=07d2a2b9-5e8e-4924-836e-264f8438f6c5;IngestionEndpoint=https://northeurope-2.in.applicationinsights.azure.com/;LiveEndpoint=https://northeurope.livediagnostics.monitor.azure.com/;ApplicationId=56748c39-2fa3-4880-a1e2-24068e791548" "ConnectionString": "InstrumentationKey=07d2a2b9-5e8e-4924-836e-264f8438f6c5;IngestionEndpoint=https://northeurope-2.in.applicationinsights.azure.com/;LiveEndpoint=https://northeurope.livediagnostics.monitor.azure.com/;ApplicationId=56748c39-2fa3-4880-a1e2-24068e791548"
},
"Feature": {
"Enabled": true,
"RolloutPercentage": 25,
"AllowedUserGroups": [ "beta" ]
} }
} }

View file

@ -1,50 +0,0 @@

using Microsoft.Extensions.Configuration;
namespace Core.Configurations
{
public class AzureConfigurationManager
{
private static IConfigurationBuilder _configurationBuilder;
/// <summary>
/// This AppConfigBuilder assumes that AppConfigEndpoint and AppConfigLabelFilter are configured as Settings on Azure for the Application that needs them.
/// AppConfigEndpoint would look like this: Endpoint=https://config-dec-test.azconfig.io;Id=0-l9-s0:foo;Secret=somesecret/bar
/// </summary>
/// <param name="localSettingsFile">
/// Path relative to the base path stored in Microsoft.Extensions.Configuration.IConfigurationBuilder.Properties of builder.
/// </param>
public static IConfigurationBuilder AppConfigBuilder(string localSettingsFile)
{
if (_configurationBuilder == null)
{
var envConfiguration = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.TextWriterTraceListener(Console.Out));
var appConfigEndpoint = envConfiguration["AppConfigEndpoint"];
var appConfigLabel = envConfiguration["AppConfigLabelFilter"];
_configurationBuilder = new Microsoft.Extensions.Configuration.ConfigurationBuilder();
if (!string.IsNullOrEmpty(appConfigEndpoint))
{
_configurationBuilder
.AddAzureAppConfiguration(options =>
{
options.Connect(appConfigEndpoint);
options.Select(keyFilter: "*", labelFilter: appConfigLabel);
})
.AddEnvironmentVariables();
}
else
{
_configurationBuilder.SetBasePath(Directory.GetCurrentDirectory());
_configurationBuilder.AddJsonFile(localSettingsFile, optional: false);
}
}
return _configurationBuilder;
}
}
}

View file

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Core.Configurations.Common
{
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
public static class KeyValueToJson
{
public static JObject Convert(IEnumerable<KeyValuePair<string, JToken>> pairs)
{
var root = new JObject();
foreach (var pair in pairs)
{
var keys = pair.Key.Split(':');
var current = root;
// Gennemgå hierarkiet og opret underobjekter, hvis de ikke eksisterer
for (int i = 0; i < keys.Length - 1; i++)
{
var key = keys[i];
if (current[key] == null)
{
current[key] = new JObject();
}
current = (JObject)current[key];
}
// Håndter den sidste nøgle og tilføj værdien
var lastKey = keys[keys.Length - 1];
var value = ConvertValue(pair.Value);
// Hvis den sidste nøgle allerede eksisterer, tilføj til en liste
if (current[lastKey] != null)
{
// Hvis den allerede er en liste, tilføj til listen
if (current[lastKey].Type == JTokenType.Array)
{
((JArray)current[lastKey]).Add(value);
}
// Hvis den ikke er en liste, konverter til en liste
else
{
var existingValue = current[lastKey];
current[lastKey] = new JArray { existingValue, value };
}
}
// Ellers tilføj som en enkelt værdi
else
{
current[lastKey] = value;
}
}
return root;
}
private static JToken ConvertValue(object value)
{
// Hvis værdien allerede er en JToken, returner den direkte
if (value is JToken token)
{
return token;
}
// Konverter andre typer
return value switch
{
int i => new JValue(i),
double d => new JValue(d),
bool b => new JValue(b),
string s => new JValue(s),
_ => new JValue(value.ToString())
};
}
}
}

View file

@ -1,61 +1,116 @@
using System; using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Core.Configurations namespace Core.Configurations
{ {
public class ConfigurationBuilder public interface IConfigurationBuilder
{ {
private readonly List<IConfigurationProvider> _providers = new(); ConfigurationBuilder AddProvider(IConfigurationProvider provider);
IConfigurationRoot Build();
List<IConfigurationProvider> ConfigurationProviders { get; }
}
public ConfigurationBuilder AddProvider(IConfigurationProvider provider) public class ConfigurationBuilder : IConfigurationBuilder
{ {
_providers.Add(provider); public List<IConfigurationProvider> ConfigurationProviders { get; private set; } = [];
return this;
}
public IConfigurationRoot Build() public ConfigurationBuilder AddProvider(IConfigurationProvider provider)
{ {
// Her kan du implementere din egen sammenlægningslogik ((IConfigurationBuilder)this).ConfigurationProviders.Add(provider);
return new ConfigurationRoot(_providers); return this;
} }
} public IConfigurationRoot Build()
{
foreach (var provider in ConfigurationProviders)
{
provider.Build();
}
//TODO: we need to come up with merge strategy
public class ConfigurationRoot : IConfigurationRoot return new ConfigurationRoot(ConfigurationProviders);
{ }
public ConfigurationRoot(List<IConfigurationProvider> configurationProviders) }
{
} public class ConfigurationRoot : IConfigurationRoot
public T GetSection<T>(string key) {
{ List<IConfigurationProvider> IConfigurationRoot.ConfigurationProviders { get; set; }
throw new NotImplementedException();
}
}
public static class ConfigurationPredicateExtensions
{
public static IConfigurationSection GetSection(this IConfigurationRoot configurationSection, string key) public ConfigurationRoot(List<IConfigurationProvider> configurationProviders)
{ {
return null; ((IConfigurationRoot)this).ConfigurationProviders = configurationProviders;
} }
public static T Get<T>(this IConfigurationSection configuration, string key) }
{ public static class ConfigurationBinder
return default(T); {
} private static string NormalizePath(string path)
} {
public interface IConfigurationProvider return path?.Replace(":", ".", StringComparison.Ordinal) ?? string.Empty;
{ }
Dictionary<string, object> Configuration(); public static string GetConnectionString(this IConfigurationRoot configuration, string name)
} {
return configuration.GetSection("ConnectionStrings").Get<string>(name);
}
public static IConfigurationSection GetSection(this IConfigurationRoot configuration, string path)
{
JToken value = null;
foreach (var provider in configuration.ConfigurationProviders)
{
var test = provider.Configuration().SelectToken(NormalizePath(path));
public interface IConfigurationSection if (test != null)
{ value = test;
string Key { get; } }
string Path { get; }
string Value { get; set; } return new ConfigurationSection { Path = path, Key = path.Split(':').Last(), Value = value };
} }
public static T Get<T>(this IConfigurationRoot configuration, string path)
{
JToken value = null;
foreach (var provider in configuration.ConfigurationProviders)
{
var test = provider.Configuration().SelectToken(NormalizePath(path));
if (test != null)
value = test;
}
return value.ToObject<T>();
}
public static T Get<T>(this IConfigurationSection configuration, string path)
{
var value = configuration.Value.SelectToken(NormalizePath(path)).ToObject<T>();
return value;
}
public static T ToObject<T>(this IConfigurationSection configuration)
{
var value = configuration.Value.ToObject<T>();
return value;
}
[Obsolete("Use ToObject")]
public static T Get<T>(this IConfigurationSection configuration)
{
return configuration.Value.ToObject<T>();
}
}
public interface IConfigurationProvider
{
void Build();
Newtonsoft.Json.Linq.JObject Configuration();
}
public class ConfigurationSection : IConfigurationSection
{
public required string Path { get; set; }
public required string Key { get; set; }
public required JToken Value { get; set; }
}
public interface IConfigurationSection
{
string Path { get; }
string Key { get; }
Newtonsoft.Json.Linq.JToken Value { get; set; }
}
} }

View file

@ -1,14 +1,7 @@
using System; namespace Core.Configurations
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Core.Configurations
{ {
public interface IConfigurationRoot public interface IConfigurationRoot
{ {
internal List<IConfigurationProvider> ConfigurationProviders { get; set; }
} }
} }

View file

@ -0,0 +1,54 @@
using Core.Exceptions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Core.Configurations.JsonConfigProvider
{
public static class JsonConfigExtension
{
public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string configurationFilePath = "appconfiguration.json", bool? optional = true, bool? reloadOnChange = false)
{
return builder.AddProvider(new JsonConfigProvider(builder, configurationFilePath, optional ?? true, reloadOnChange ?? false));
}
}
public interface IHasConfigurationFilePath
{
string ConfigurationFilePath { get; }
}
public class JsonConfigProvider : IConfigurationProvider, IHasConfigurationFilePath
{
private readonly IConfigurationBuilder _builder;
private readonly bool _reloadOnChange;
JObject _configuration;
public string ConfigurationFilePath { get; private set; }
public JsonConfigProvider() { }
public JsonConfigProvider(IConfigurationBuilder builder, string configurationFilePath, bool optional, bool reloadOnChange)
{
if (!optional && !File.Exists(configurationFilePath))
throw new ConfigurationException($"File not found, path: {configurationFilePath}");
if (optional && !File.Exists(configurationFilePath))
return;
ConfigurationFilePath = configurationFilePath;
_builder = builder;
_reloadOnChange = reloadOnChange;
}
public void Build()
{
using (StreamReader file = File.OpenText(ConfigurationFilePath))
using (JsonTextReader reader = new JsonTextReader(file))
{
_configuration = (JObject)JToken.ReadFrom(reader);
}
}
public JObject Configuration()
{
return _configuration;
}
}
}

View file

@ -1,115 +0,0 @@
using Microsoft.Extensions.Configuration;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Core.Configurations.PostgresqlConfigurationBuilder
{
public class PostgresConfigurationSource : Microsoft.Extensions.Configuration.IConfigurationSource
{
private readonly string _connectionString;
private readonly string _channel;
private readonly string _configurationQuery;
public PostgresConfigurationSource(string connectionString, string channel, string configurationQuery)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
_configurationQuery = configurationQuery ?? throw new ArgumentNullException(nameof(configurationQuery));
}
public Microsoft.Extensions.Configuration.IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new PostgresConfigurationProvider(_connectionString, _channel, _configurationQuery);
}
}
public class PostgresConfigurationProvider : ConfigurationProvider, IDisposable
{
private readonly string _connectionString;
private readonly string _channel;
private readonly string _configurationQuery;
private readonly NpgsqlConnection _listenerConnection;
private bool _disposedValue;
public PostgresConfigurationProvider(string connectionString, string channel, string configurationQuery)
{
_connectionString = connectionString;
_channel = channel;
_configurationQuery = configurationQuery;
_listenerConnection = new NpgsqlConnection(connectionString);
// Start listening for notifications
StartListening();
}
private async void StartListening()
{
try
{
await _listenerConnection.OpenAsync();
_listenerConnection.Notification += OnNotificationReceived;
using var cmd = new NpgsqlCommand($"LISTEN {_channel};", _listenerConnection);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
// Log error and possibly retry
Console.WriteLine($"Error starting listener: {ex.Message}");
}
}
private void OnNotificationReceived(object sender, NpgsqlNotificationEventArgs e)
{
if (e.Channel == _channel)
{
// Reload configuration and notify
Load();
OnReload();
}
}
public override void Load()
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using (var connection = new NpgsqlConnection(_connectionString))
{
connection.Open();
using var cmd = new NpgsqlCommand(_configurationQuery, connection);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
string key = reader.GetString(0);
string value = reader.GetString(1);
data[key] = value;
}
}
Data = data;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_listenerConnection?.Dispose();
}
_disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View file

@ -1,31 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Core.Configurations.PostgresqlConfigurationBuilder
{
/// <summary>
/// LISTEN / NOTIFY in Postgresql
/// </summary>
public static class PostgresConfigurationExtensions
{
public static IConfigurationBuilder AddPostgresConfiguration(
this IConfigurationBuilder builder,
Action<PostgresConfigurationOptions> setupAction)
{
var options = new PostgresConfigurationOptions();
setupAction(options);
builder.Add(new PostgresConfigurationSource(
options.ConnectionString,
options.Channel,
options.ConfigurationQuery));
return builder;
}
}
public class PostgresConfigurationOptions
{
public string ConnectionString { get; set; }
public string Channel { get; set; }
public string ConfigurationQuery { get; set; }
}
}

View file

@ -0,0 +1,14 @@
namespace Core.Configurations.SmartConfigProvider;
public class AppConfiguration
{
public long Id { get; set; }
public string Key { get; set; }
public object Value { get; set; }
public string Label { get; set; }
public string ContentType { get; set; }
public DateTime? ValidFrom { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public Guid? Etag { get; set; }
}

View file

@ -0,0 +1,31 @@
using System.Data;
using Core.Configurations.SmartConfiguration;
using Insight.Database;
namespace Core.Configurations.SmartConfigProvider;
public class ConfigurationRepository : IConfigurationRepository
{
private readonly IDbConnection _connection;
public ConfigurationRepository(IDbConnection connection)
{
_connection = connection;
}
public ConfigurationRepository(string connectionString)
{
_connection = new Npgsql.NpgsqlConnection(connectionString);
}
public IEnumerable<AppConfiguration> GetActiveConfigurations()
{
const string sql = @"
SELECT id, ""key"", value, label, content_type,
valid_from, expires_at, created_at, modified_at, etag
FROM app_configuration
WHERE CURRENT_TIMESTAMP BETWEEN valid_from AND expires_at
OR (valid_from IS NULL AND expires_at IS NULL)";
return _connection.QuerySql<AppConfiguration>(sql);
}
}

View file

@ -0,0 +1,7 @@
using Core.Configurations.SmartConfigProvider;
namespace Core.Configurations.SmartConfiguration;
public interface IConfigurationRepository
{
IEnumerable<AppConfiguration> GetActiveConfigurations();
}

View file

@ -0,0 +1,74 @@
using Core.Configurations.JsonConfigProvider;
using Core.Configurations.SmartConfigProvider;
using Core.Exceptions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Core.Configurations.SmartConfig
{
public static class SmartConfigExtension
{
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, string configKey = "DefaultConnection", string path = null)
{
return builder.AddProvider(new SmartConfigProvider(builder, configKey, path));
}
}
public class SmartConfigProvider : IConfigurationProvider
{
string _configKey;
string _connectionString;
string _path;
IConfigurationBuilder _builder;
Newtonsoft.Json.Linq.JObject _configuration;
public SmartConfigProvider() { }
public SmartConfigProvider(IConfigurationBuilder builder, string configKey, string configurationFilePath)
{
_builder = builder;
_configKey = configKey;
_path = configurationFilePath;
SetConnectionString();
}
void SetConnectionString()
{
var carrier = _builder.ConfigurationProviders.OfType<IHasConfigurationFilePath>().SingleOrDefault();
if (carrier?.ConfigurationFilePath is null && _path is null)
throw new ConfigurationException($"Expected a previous added ConfigurationProvider with IHasConfigurationFilePath or a configurationFilePath where to find the appsettingsfile");
_path ??= carrier.ConfigurationFilePath;
if (!File.Exists(_path))
throw new ConfigurationException($"File not found, configurationFilePath: {_path}");
using (StreamReader file = File.OpenText(_path))
using (JsonTextReader reader = new JsonTextReader(file))
{
var jsonConfiguration = (Newtonsoft.Json.Linq.JObject)Newtonsoft.Json.Linq.JToken.ReadFrom(reader);
_connectionString = jsonConfiguration.SelectToken($"ConnectionStrings.{_configKey}")?.ToString();
}
}
public void Build()
{
var repository = new ConfigurationRepository(_connectionString);
var configs = repository.GetActiveConfigurations();
var pairs = configs.Select(x => new KeyValuePair<string, JToken>(x.Key, JToken.Parse(x.Value.ToString())));
_configuration = Common.KeyValueToJson.Convert(pairs);
}
public Newtonsoft.Json.Linq.JObject Configuration()
{
return _configuration;
}
}
}

View file

@ -1,14 +0,0 @@
namespace Core.Configurations.SmartConfiguration;
public class AppConfiguration
{
public long Id { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public string Label { get; set; }
public string ContentType { get; set; }
public DateTime? ValidFrom { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public Guid? Etag { get; set; }
}

View file

@ -1,27 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Core.Configurations.SmartConfiguration
{
public static class ConfigurationExtensions
{
public static T Get<T>(this IConfigurationSection section) where T : class
{
if (section is JsonConfigurationSection jsonSection)
{
var token = jsonSection.GetToken();
return token?.ToObject<T>();
}
throw new InvalidOperationException("Section is not a JsonConfigurationSection");
}
public static T GetValue<T>(this IConfigurationSection section, string key)
{
if (section is JsonConfigurationSection jsonSection)
{
var token = jsonSection.GetToken().SelectToken(key.Replace(":", "."));
return token.ToObject<T>();
}
throw new InvalidOperationException("Section is not a JsonConfigurationSection");
}
}
}

View file

@ -1,24 +0,0 @@
using System.Data;
using Insight.Database;
namespace Core.Configurations.SmartConfiguration;
public class ConfigurationRepository : IConfigurationRepository
{
private readonly IDbConnection _connection;
public ConfigurationRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<IEnumerable<AppConfiguration>> GetActiveConfigurations()
{
const string sql = @"
SELECT id, key, value, label, content_type, valid_from, expires_at, created_at, modified_at, etag
FROM prod.app_configuration
WHERE (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
AND (valid_from IS NULL OR valid_from < CURRENT_TIMESTAMP)";
return await _connection.QueryAsync<AppConfiguration>(sql);
}
}

View file

@ -1,5 +0,0 @@
namespace Core.Configurations.SmartConfiguration;
public interface IConfigurationRepository
{
Task<IEnumerable<AppConfiguration>> GetActiveConfigurations();
}

View file

@ -1,34 +0,0 @@
using Newtonsoft.Json.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using System.Data;
namespace Core.Configurations.SmartConfiguration;
public class JsonConfiguration : Microsoft.Extensions.Configuration.IConfiguration
{
private readonly JObject _data;
public JsonConfiguration(JObject data)
{
_data = data;
}
public string this[string key]
{
get => _data.SelectToken(key.Replace(":", "."))?.ToString();
set => throw new NotImplementedException();
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key) => null;
//new JsonConfigurationSection(_data, key);
public IEnumerable<IConfigurationSection> GetChildren() =>
_data.Properties().Select(p => new JsonConfigurationSection(_data, p.Name));
public IChangeToken GetReloadToken() => throw new NotImplementedException();
IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> IConfiguration.GetChildren()
{
throw new NotImplementedException();
}
}

View file

@ -1,80 +0,0 @@
using Newtonsoft.Json.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace Core.Configurations.SmartConfiguration
{
public class JsonConfigurationSection : IConfigurationSection
{
private readonly JObject _data;
private readonly string _path;
private readonly string _normalizedPath;
public JsonConfigurationSection(JObject data, string path)
{
_data = data ?? throw new ArgumentNullException(nameof(data));
_path = path ?? throw new ArgumentNullException(nameof(path));
_normalizedPath = NormalizePath(_path);
}
public string this[string key]
{
get
{
if (string.IsNullOrEmpty(key))
throw new ArgumentNullException(nameof(key));
var token = _data.SelectToken($"{_normalizedPath}.{NormalizePath(key)}");
return token?.ToString();
}
set => throw new NotImplementedException("Setting values is not supported.");
}
public string Key => _path.Split(':').Last();
public string Path => _path;
public string Value
{
get
{
var token = _data.SelectToken(_normalizedPath);
return token?.ToString();
}
set => throw new NotImplementedException("Setting values is not supported.");
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentNullException(nameof(key));
return null;// new JsonConfigurationSection(_data, string.IsNullOrEmpty(_path) ? key : $"{_path}:{key}");
}
public JToken GetToken() => _data.SelectToken(_normalizedPath);
public IEnumerable<IConfigurationSection> GetChildren()
{
var token = _data.SelectToken(_normalizedPath);
if (token is JObject obj)
{
return obj.Properties()
.Select(p => new JsonConfigurationSection(_data, string.IsNullOrEmpty(_path) ? p.Name : $"{_path}:{p.Name}"));
}
return Enumerable.Empty<IConfigurationSection>();
}
public T Get<T>() where T : class
{
var token = _data.SelectToken(_normalizedPath);
return token?.ToObject<T>();
}
public IChangeToken GetReloadToken() => new ConfigurationReloadToken();
private static string NormalizePath(string path)
{
return path?.Replace(":", ".", StringComparison.Ordinal) ?? string.Empty;
}
}
}

View file

@ -1,83 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Microsoft.Extensions.Configuration;
namespace Core.Configurations.SmartConfiguration
{
public class KeyValueConfigurationBuilder
{
private readonly IConfigurationRepository _repository;
private readonly JObject _rootObject = new();
private IConfiguration _configuration;
private readonly object _configurationLock = new();
public KeyValueConfigurationBuilder(IConfigurationRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
/// <summary>
/// Loads configurations from the repository and builds the configuration tree.
/// </summary>
public async Task LoadConfiguration()
{
try
{
var configurations = await _repository.GetActiveConfigurations();
foreach (var config in configurations)
AddKeyValue(config.Key, config.Value);
}
catch (Exception ex)
{
// Log the exception or handle it as needed
throw new InvalidOperationException("Failed to load configurations.", ex);
}
}
/// <summary>
/// Adds a key-value pair to the configuration tree.
/// </summary>
/// <param name="key">The key to add.</param>
/// <param name="jsonValue">The JSON value to add.</param>
public void AddKeyValue(string key, string jsonValue)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentNullException(nameof(key));
if (string.IsNullOrEmpty(jsonValue))
throw new ArgumentNullException(nameof(jsonValue));
try
{
var valueObject = JsonConvert.DeserializeObject<JObject>(jsonValue);
var parts = key.Split(':');
JObject current = _rootObject;
for (int i = 0; i < parts.Length - 1; i++)
{
var part = parts[i];
if (!current.ContainsKey(part))
{
current[part] = new JObject();
}
current = (JObject)current[part];
}
current[parts[^1]] = valueObject;
}
catch (JsonException ex)
{
throw new ArgumentException("Invalid JSON value.", nameof(jsonValue), ex);
}
}
/// <summary>
/// Builds the configuration instance.
/// </summary>
/// <returns>The built <see cref="IConfiguration"/> instance.</returns>
public IConfiguration Build()
{
_configuration = new JsonConfiguration(_rootObject);
return _configuration;
}
}
}

View file

@ -1,48 +0,0 @@

using Microsoft.Extensions.Configuration;
namespace Core.Configurations
{
public class SmartConfigManager
{
private static IConfigurationBuilder _configurationBuilder;
/// <summary>
/// This AppConfigBuilder assumes that AppConfigEndpoint and AppConfigLabelFilter are configured as Settings on Azure for the Application that needs them.
/// AppConfigEndpoint would look like this: Endpoint=https://config-dec-test.azconfig.io;Id=0-l9-s0:foo;Secret=somesecret/bar
/// </summary>
/// <param name="localSettingsFile">
/// Path relative to the base path stored in Microsoft.Extensions.Configuration.IConfigurationBuilder.Properties of builder.
/// </param>
public static IConfigurationBuilder AddSmartConfigBuilder(string localSettingsFile)
{
if (_configurationBuilder == null)
{
var envConfiguration = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var appConfigEndpoint = envConfiguration["AppConfigEndpoint"];
var appConfigLabel = envConfiguration["AppConfigLabelFilter"];
_configurationBuilder = new Microsoft.Extensions.Configuration.ConfigurationBuilder();
if (!string.IsNullOrEmpty(appConfigEndpoint))
{
_configurationBuilder
.AddAzureAppConfiguration(options =>
{
options.Connect(appConfigEndpoint);
options.Select(keyFilter: "*", labelFilter: appConfigLabel);
})
.AddEnvironmentVariables();
}
else
{
_configurationBuilder.SetBasePath(Directory.GetCurrentDirectory());
_configurationBuilder.AddJsonFile(localSettingsFile, optional: false);
}
}
return _configurationBuilder;
}
}
}

View file

@ -1,22 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Akka" Version="1.5.32" /> <PackageReference Include="Akka" Version="1.5.32" />
<PackageReference Include="Autofac" Version="8.1.1" /> <PackageReference Include="Autofac" Version="8.1.1" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" /> <PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Insight.Database" Version="8.0.1" /> <PackageReference Include="Insight.Database" Version="8.0.1" />
<PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1" /> <PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1" />
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0" /> <PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" /> <PackageReference Include="npgsql" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.AzureAppConfiguration" Version="8.0.0" /> </ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" /> <ItemGroup>
<PackageReference Include="npgsql" Version="9.0.2" /> <Folder Include="Configurations\AzureAppConfigurationProvider\" />
</ItemGroup> <Folder Include="Configurations\PostgresqlConfigurationBuilder\" />
</ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace Database.Common
{
internal class Validations
{
public static bool IsValidSchemaName(string schema)
{
return !string.IsNullOrEmpty(schema) && Regex.IsMatch(schema, "^[a-zA-Z0-9_]+$");
}
}
}

View file

@ -1,7 +1,7 @@
using Insight.Database; using Insight.Database;
using System.Data; using System.Data;
namespace Database.AppConfigurationSystem; namespace Database.ConfigurationManagementSystem;
public class ConfigurationDatabaseSetup public class ConfigurationDatabaseSetup
{ {

View file

@ -1,25 +1,66 @@
using Insight.Database; using Database.Common;
using Insight.Database;
using System; using System;
using System.Data; using System.Data;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Database.Tenants namespace Database.IdentitySystem
{ {
public class DbSetup public class DbSetup
{ {
private readonly IDbConnection _db; readonly IDbConnection _db;
IDbTransaction _transaction = null;
string _schema;
public DbSetup(IDbConnection db) public DbSetup(IDbConnection db)
{ {
_db = db ?? throw new ArgumentNullException(nameof(db)); _db = db ?? throw new ArgumentNullException(nameof(db));
} }
/// <summary>
/// Creates the system tables in the specified schema within a transaction.
/// </summary>
/// <param name="schema">The schema name where the tables will be created.</param>
public async Task CreateSystem(string schema)
{
_schema = schema;
if (!Validations.IsValidSchemaName(schema))
throw new ArgumentException("Invalid schema name", nameof(schema));
using (_transaction = _db.BeginTransaction())
{
try
{
await CreateUsersTable().ConfigureAwait(false);
await CreateTenantsTable().ConfigureAwait(false);
await CreateUserTenantsTable().ConfigureAwait(false);
await SetupRLS().ConfigureAwait(false);
_transaction.Commit();
}
catch (Exception ex)
{
_transaction.Rollback();
throw new InvalidOperationException("Failed to create system tables.", ex);
}
}
}
private async Task ExecuteSqlAsync(string sql)
{
if (string.IsNullOrEmpty(sql))
throw new ArgumentNullException(nameof(sql));
await _db.ExecuteAsync(sql).ConfigureAwait(false);
}
/// <summary> /// <summary>
/// Creates the users table in the ptmain schema. /// Creates the users table in the ptmain schema.
/// </summary> /// </summary>
public void CreateUsersTable() public async Task CreateUsersTable()
{ {
ExecuteInTransaction(@" var sql = @"
CREATE TABLE IF NOT EXISTS ptmain.users ( CREATE TABLE IF NOT EXISTS ptmain.users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email VARCHAR(256) NOT NULL UNIQUE, email VARCHAR(256) NOT NULL UNIQUE,
@ -32,85 +73,75 @@ namespace Database.Tenants
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMPTZ NULL last_login_at TIMESTAMPTZ NULL
);"); );";
await ExecuteSqlAsync(sql).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Creates the tenants table in the ptmain schema. /// Creates the tenants table in the ptmain schema.
/// </summary> /// </summary>
public void CreateTenantsTable() public async Task CreateTenantsTable()
{ {
ExecuteInTransaction(@" var sql = @"
CREATE TABLE IF NOT EXISTS ptmain.tenants ( CREATE TABLE IF NOT EXISTS ptmain.tenants (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
connection_string VARCHAR(500) NOT NULL, connection_string VARCHAR(500) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by INTEGER NOT NULL REFERENCES ptmain.users(id), created_by INTEGER NOT NULL REFERENCES ptmain.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);"); );";
await ExecuteSqlAsync(sql).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Creates the user_tenants table in the ptmain schema. /// Creates the user_tenants table in the ptmain schema.
/// </summary> /// </summary>
public void CreateUserTenantsTable() public async Task CreateUserTenantsTable()
{ {
ExecuteInTransaction(@" var sql = @"
CREATE TABLE IF NOT EXISTS ptmain.user_tenants ( CREATE TABLE IF NOT EXISTS ptmain.user_tenants (
user_id INTEGER NOT NULL REFERENCES ptmain.users(id), user_id INTEGER NOT NULL REFERENCES ptmain.users(id),
tenant_id INTEGER NOT NULL REFERENCES ptmain.tenants(id), tenant_id INTEGER NOT NULL REFERENCES ptmain.tenants(id),
pin_code VARCHAR(10) NULL, pin_code VARCHAR(10) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, tenant_id) PRIMARY KEY (user_id, tenant_id)
);"); );";
await ExecuteSqlAsync(sql).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Sets up Row Level Security (RLS) for the tenants and user_tenants tables. /// Sets up Row Level Security (RLS) for the tenants and user_tenants tables.
/// </summary> /// </summary>
public void SetupRLS() public async Task SetupRLS()
{ {
ExecuteInTransaction( var sql = new[]
"ALTER TABLE ptmain.tenants ENABLE ROW LEVEL SECURITY;",
"ALTER TABLE ptmain.user_tenants ENABLE ROW LEVEL SECURITY;",
"DROP POLICY IF EXISTS tenant_access ON ptmain.tenants;",
@"
CREATE POLICY tenant_access ON ptmain.tenants
USING (id IN (
SELECT tenant_id
FROM ptmain.user_tenants
WHERE user_id = current_setting('app.user_id', TRUE)::INTEGER
));",
"DROP POLICY IF EXISTS user_tenant_access ON ptmain.user_tenants;",
@"
CREATE POLICY user_tenant_access ON ptmain.user_tenants
USING (user_id = current_setting('app.user_id', TRUE)::INTEGER);"
);
}
/// <summary>
/// Executes one or more SQL commands within a transaction.
/// </summary>
/// <param name="sqlCommands">The SQL commands to execute.</param>
private void ExecuteInTransaction(params string[] sqlCommands)
{
if (_db.State != ConnectionState.Open)
_db.Open();
using var transaction = _db.BeginTransaction();
try
{
foreach (var sql in sqlCommands)
{ {
_db.Execute(sql, transaction: transaction); "ALTER TABLE ptmain.tenants ENABLE ROW LEVEL SECURITY;",
} "ALTER TABLE ptmain.user_tenants ENABLE ROW LEVEL SECURITY;",
transaction.Commit(); "DROP POLICY IF EXISTS tenant_access ON ptmain.tenants;",
} @"CREATE POLICY tenant_access ON ptmain.tenants
catch (Exception ex) USING (id IN (
SELECT tenant_id
FROM ptmain.user_tenants
WHERE user_id = current_setting('app.user_id', TRUE)::INTEGER
));",
"DROP POLICY IF EXISTS user_tenant_access ON ptmain.user_tenants;",
@"CREATE POLICY user_tenant_access ON ptmain.user_tenants
USING (user_id = current_setting('app.user_id', TRUE)::INTEGER);"
};
foreach (var statement in sql)
{ {
transaction.Rollback(); await ExecuteSqlAsync(statement).ConfigureAwait(false);
throw new InvalidOperationException("Failed to execute SQL commands in transaction.", ex);
} }
} }
} }
} }

View file

@ -2,7 +2,7 @@
using Insight.Database; using Insight.Database;
using System.Data; using System.Data;
namespace Database.Identity namespace Database.IdentitySystem
{ {
public class UserService public class UserService
{ {

View file

@ -1,8 +1,9 @@
using System.Data; using System.Data;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Database.Common;
using Insight.Database; using Insight.Database;
namespace Database.Tenants namespace Database.RolesPermissionSystem
{ {
public class Setup public class Setup
{ {
@ -19,7 +20,7 @@ namespace Database.Tenants
/// <param name="schema">The schema name where the tables will be created.</param> /// <param name="schema">The schema name where the tables will be created.</param>
public async Task CreateSystem(string schema) public async Task CreateSystem(string schema)
{ {
if (!IsValidSchemaName(schema)) if (!Validations.IsValidSchemaName(schema))
throw new ArgumentException("Invalid schema name", nameof(schema)); throw new ArgumentException("Invalid schema name", nameof(schema));
using (var transaction = _db.BeginTransaction()) using (var transaction = _db.BeginTransaction())
@ -29,7 +30,7 @@ namespace Database.Tenants
await CreateRolesTable(schema, transaction).ConfigureAwait(false); await CreateRolesTable(schema, transaction).ConfigureAwait(false);
await CreatePermissionsTable(schema, transaction).ConfigureAwait(false); await CreatePermissionsTable(schema, transaction).ConfigureAwait(false);
await CreatePermissionTypesTable(schema, transaction).ConfigureAwait(false); await CreatePermissionTypesTable(schema, transaction).ConfigureAwait(false);
await CreateRolePermissionsTable(schema, transaction).ConfigureAwait(false); await CreateRolePermissionsTable(schema, transaction).ConfigureAwait(false);
transaction.Commit(); transaction.Commit();
} }
@ -41,10 +42,6 @@ namespace Database.Tenants
} }
} }
private bool IsValidSchemaName(string schema)
{
return !string.IsNullOrEmpty(schema) && Regex.IsMatch(schema, "^[a-zA-Z0-9_]+$");
}
private async Task ExecuteSqlAsync(string sql, IDbTransaction transaction) private async Task ExecuteSqlAsync(string sql, IDbTransaction transaction)
{ {

View file

@ -12,7 +12,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.json"> <None Update="appconfiguration.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
</ItemGroup> </ItemGroup>

View file

@ -1,7 +1,7 @@
{ {
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"ptdb": "Host=192.168.1.57;Port=5432;Database=ptdb01;User Id=postgres;Password=3911;" "ptdb": "Host=192.168.1.57;Port=5432;Database=ptdb01;User Id=sathumper;Password=3911;"
}, },
"ApplicationInsights": { "ApplicationInsights": {
"ConnectionString": "InstrumentationKey=6d2e76ee-5343-4691-a5e3-81add43cb584;IngestionEndpoint=https://northeurope-0.in.applicationinsights.azure.com/" "ConnectionString": "InstrumentationKey=6d2e76ee-5343-4691-a5e3-81add43cb584;IngestionEndpoint=https://northeurope-0.in.applicationinsights.azure.com/"

View file

@ -1 +1 @@
{"resources":{"Scripts/Script-2.sql":{"default-datasource":"postgres-jdbc-1948450a8b4-5fc9eec404e65c44","default-catalog":"ptdb01"},"Scripts/grant-privileges.sql":{"default-datasource":"postgres-jdbc-1948450a8b4-5fc9eec404e65c44","default-catalog":"ptdb01"}}} {"resources":{"Scripts/Script-1.sql":{"default-datasource":"postgres-jdbc-19484872d85-cd2a4a40116e706","default-catalog":"ptdb01","default-schema":"ptmain"},"Scripts/Script-2.sql":{"default-datasource":"postgres-jdbc-1948450a8b4-5fc9eec404e65c44","default-catalog":"ptdb01"},"Scripts/grant-privileges.sql":{"default-datasource":"postgres-jdbc-1948450a8b4-5fc9eec404e65c44","default-catalog":"ptdb01"}}}

View file

@ -0,0 +1,119 @@
using Core.Configurations.SmartConfig;
using Core.Configurations;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using Core.Configurations.JsonConfigProvider;
using Autofac;
using System.Data;
using Insight.Database;
using Npgsql;
namespace Tests.ConfigurationTests
{
[TestClass]
public class JsonConfigurationProviderTests : TestFixture
{
[TestMethod]
public void GetSection_ShouldReturnCorrectFeatureSection()
{
// Arrange
var expectedJObject = JObject.Parse(@"{
'Enabled': true,
'RolloutPercentage': 25,
'AllowedUserGroups': ['beta']
}") as JToken;
var builder = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.Build();
// Act
var section = builder.GetSection("Feature");
// Assert
section.Should().NotBeNull();
section.Value.Should().BeEquivalentTo(expectedJObject);
}
[TestMethod]
public void Get_ShouldReturnCorrectFeatureObject()
{
// Arrange
var expectedFeature = new Feature
{
Enabled = true,
RolloutPercentage = 25,
AllowedUserGroups = new() { "beta" }
};
var builder = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.Build();
// Act
var actualFeature = builder.GetSection("Feature").ToObject<Feature>();
var actualFeatureObsoleted = builder.GetSection("Feature").Get<Feature>();
// Assert
actualFeature.Should().BeEquivalentTo(expectedFeature);
actualFeatureObsoleted.Should().BeEquivalentTo(expectedFeature);
}
[TestMethod]
public void Get_ShouldReturnCorrectValueAsString()
{
// Arrange
var expectedFeature = "123";
var builder = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.Build();
// Act
var actualFeature = builder.GetSection("AnotherSetting").Get<string>("Thresholds:High");
// Assert
actualFeature.Should().BeEquivalentTo(expectedFeature);
}
[TestMethod]
public void Get_ShouldReturnCorrectValueAsInt()
{
// Arrange
var expectedFeature = 22;
var builder = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.Build();
// Act
var actualFeature = builder.GetSection("AnotherSetting:Temperature").Get<int>("Indoor:Max:Limit");
// Assert
actualFeature.Should().Be(expectedFeature);
}
[TestMethod]
public void Get_ShouldReturnCorrectValueAsBool()
{
// Arrange
var expectedFeature = true;
var configRoot = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.AddSmartConfig()
.Build();
// Act
var actualFeature = configRoot.Get<bool>("Database:UseSSL");
// Assert
actualFeature.Should().Be(expectedFeature);
}
}
public class Feature
{
public bool Enabled { get; set; }
public int RolloutPercentage { get; set; }
public List<string> AllowedUserGroups { get; set; }
}
}

View file

@ -1,139 +1,78 @@
using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System.Collections.Generic; using Tests;
using System.Linq; using Core.Configurations.SmartConfig;
namespace Tests.ConfigurationTests using Core.Configurations.Common;
namespace Tests.ConfigurationTests;
[TestClass]
public class ConfigurationTests : TestFixture
{ {
[TestClass] [TestInitialize]
public class KeyValueJsonHandlingTests : TestFixture public void Init()
{ {
}
[TestMethod]
public void FunMixedTypesTest()
{
var pairs = new List<KeyValuePair<string, object>>
{
new("developer:coffeeLevel", 9001.5),
new("developer:bugsFixed", 42),
new("developer:awake", false),
new("compiler:errors:semicolon:0", "Missing ;"),
new("compiler:errors:semicolon:1", "Found ; but why?"),
new("computer:ram:status", "Out of memory"),
new("computer:cpu:temperature", 99.9),
new("friday:beer:count", 6),
new("friday:code:working", true),
new("friday:bugs:count", int.MaxValue),
new("real:object", JObject.Parse(@"{
""beer"": {
""count"": 6
},
""code"": {
""working"": true
},
""bugs"": {
""count"": 2147483647
}
}"))
};
[TestMethod]
public void ConfigurationSettingsTest()
{
var pairs = new List<KeyValuePair<string, JToken>>
{
new("Debug", true),
// Database konfiguration
new("Database:ConnectionString", "Server=db.example.com;Port=5432"),
new("Database:Timeout", 30),
new("Database:UseSSL", true),
var result = KeyValueToJson.Convert(pairs); // Logging konfiguration med JObject
new("Logging:FileOptions", JObject.Parse(@"{
'Path': '/var/logs/app.log',
'MaxSizeMB': 100,
'RetentionDays': 7
}")),
var expected = JObject.Parse(@"{ // Feature flags med kompleks konfiguration
'developer': { new("Features:Experimental", JObject.Parse(@"{
'coffeeLevel': 9001.5, 'Enabled': true,
'bugsFixed': 42, 'RolloutPercentage': 25,
'awake': false 'AllowedUserGroups': ['beta']
}, }")),
'compiler': {
'errors': { 'semicolon': ['Missing ;', 'Found ; but why?'] } // API endpoints med array
}, new("API:Endpoints", "/api/users"),
'computer': { new("API:Endpoints", "/api/products")
'ram': { 'status': 'Out of memory' }, };
'cpu': { 'temperature': 99.9 }
}, var result = KeyValueToJson.Convert(pairs);
'friday': {
'beer': { 'count': 6 }, var expected = JObject.Parse(@"{
'code': { 'working': true }, 'Debug' : true,
'bugs': { 'count': 2147483647 } 'Database': {
'ConnectionString': 'Server=db.example.com;Port=5432',
'Timeout': 30,
'UseSSL': true
},
'Logging': {
'FileOptions': {
'Path': '/var/logs/app.log',
'MaxSizeMB': 100,
'RetentionDays': 7
} }
}"); },
'Features': {
'Experimental': {
'Enabled': true,
'RolloutPercentage': 25,
'AllowedUserGroups': ['beta']
}
},
'API': {
'Endpoints': ['/api/users', '/api/products']
}
}");
Assert.IsTrue(JToken.DeepEquals(expected, result)); Assert.IsTrue(JToken.DeepEquals(expected, result));
} }
}
public static class KeyValueToJson
{
public static JObject Convert(List<KeyValuePair<string, object>> pairs)
{
var root = new JObject();
foreach (var pair in pairs)
{
var keys = pair.Key.Split(':');
var current = root;
// Gennemgå hierarkiet og opret underobjekter, hvis de ikke eksisterer
for (int i = 0; i < keys.Length - 1; i++)
{
var key = keys[i];
if (current[key] == null)
{
current[key] = new JObject();
}
current = (JObject)current[key];
}
// Håndter den sidste nøgle og tilføj værdien
var lastKey = keys[keys.Length - 1];
var value = ConvertValue(pair.Value);
// Hvis den sidste nøgle allerede eksisterer, tilføj til en liste
if (current[lastKey] != null)
{
// Hvis den allerede er en liste, tilføj til listen
if (current[lastKey].Type == JTokenType.Array)
{
((JArray)current[lastKey]).Add(value);
}
// Hvis den ikke er en liste, konverter til en liste
else
{
var existingValue = current[lastKey];
current[lastKey] = new JArray { existingValue, value };
}
}
// Ellers tilføj som en enkelt værdi
else
{
current[lastKey] = value;
}
}
return root;
}
private static JToken ConvertValue(object value)
{
// Konverter værdien til det korrekte JToken-format
return value switch
{
int i => new JValue(i),
double d => new JValue(d),
bool b => new JValue(b),
string s => new JValue(s),
_ => new JValue(value.ToString()) // Fallback for andre typer
};
}
}
} }

View file

@ -0,0 +1,102 @@
using Core.Configurations.SmartConfig;
using Core.Configurations;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using Core.Configurations.JsonConfigProvider;
using Autofac;
using System.Data;
using Insight.Database;
using Npgsql;
namespace Tests.ConfigurationTests
{
[TestClass]
public class SmartConfigProviderTests : TestFixture
{
[TestMethod]
public void Get_ShouldReturnCorrectValueAsBool()
{
// Arrange
var expectedFeature = true;
var config = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.AddSmartConfig()
.Build();
// Act
var actualFeature = config.Get<bool>("Database:UseSSL");
// Assert
actualFeature.Should().Be(expectedFeature);
}
[TestMethod]
public void Get_ShouldReturnCorrectValueWhenSelectingIntoValueRowInConfigTable()
{
// Arrange
var expectedFeature = 100;
var builder = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.AddSmartConfig()
.Build();
// Act
var actualFeature = builder.GetSection("Logging:FileOptions").Get<int>("MaxSizeMB");
var withoutSectionThisAlsoWorks = builder.Get<int>("Logging:FileOptions:MaxSizeMB");
// Assert
actualFeature.Should().Be(expectedFeature);
actualFeature.Should().Be(withoutSectionThisAlsoWorks);
}
[TestMethod]
public void GetPostgresSearchPath()
{
//ALTER USER sathumper SET search_path TO ptmain, public;
var conn = Container.Resolve<IDbConnection>();
var result = conn.QuerySql("SHOW search_path;");
using (var connw = new NpgsqlConnection(conn.ConnectionString + ";Password=3911"))
{
connw.Open();
using (var cmd = new NpgsqlCommand("SHOW search_path; SELECT current_user;", connw))
{
using (var reader = cmd.ExecuteReader())
{
reader.Read();
var r1 = $"Search path: {reader.GetString(0)}";
reader.NextResult();
reader.Read();
var r2 = $"Current schema: {reader.GetString(0)}";
}
}
}
}
[TestMethod]
public void TryGetActiveConfigurations()
{
const string sql = @"
SELECT id, ""key"", value, label, content_type,
valid_from, expires_at, created_at, modified_at, etag
FROM app_configuration
WHERE CURRENT_TIMESTAMP BETWEEN valid_from AND expires_at
OR (valid_from IS NULL AND expires_at IS NULL)";
var conn = Container.Resolve<IDbConnection>();
var result = conn.QuerySql(sql);
}
}
}

50
Tests/PostgresTests.cs Normal file
View file

@ -0,0 +1,50 @@
using Autofac;
using System.Data;
using Insight.Database;
namespace Tests
{
[TestClass]
public class PostgresTests : TestFixture
{
[TestMethod]
public void TestDefaultConnection()
{
var conn = Container.Resolve<IDbConnection>();
//https://www.reddit.com/r/dotnet/comments/6wdoyn/how_to_properly_register_dapper_on_net_core_2_di/
//https://www.code4it.dev/blog/postgres-crud-dapper/
//https://stackoverflow.com/questions/69169247/how-to-create-idbconnection-factory-using-autofac-for-dapper
conn.ExecuteSql("SELECT 1 as p");
}
[TestMethod]
public void TryTenantSetupService()
{
var conn = Container.Resolve<IDbConnection>();
}
[TestMethod]
public async Task TryDbSetup()
{
var conn = Container.Resolve<IDbConnection>();
var dbSetup = new Database.IdentitySystem.DbSetup(conn);
await dbSetup.CreateSystem("swp");
}
[TestMethod]
public void SetupPostgresql_LISTEN()
{
//var builder = new ConfigurationBuilder()
// .AddPostgresConfiguration(options =>
// {
// options.ConnectionString = "Host=192.168.1.57;Database=ptdb01;Username=postgres;Password=3911";
// options.Channel = "config_changes";
// options.ConfigurationQuery = @"select * from dev.app_configuration";
// });
}
}
}

View file

@ -1,173 +0,0 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Tests;
using Microsoft.Extensions.Configuration;
using Core.Configurations.SmartConfiguration;
namespace Configuration.Core.Tests;
[TestClass]
public class ConfigurationTests : TestFixture
{
private Mock<IConfigurationRepository> _mockRepo;
private KeyValueConfigurationBuilder _builder;
[TestInitialize]
public void Init()
{
_mockRepo = new Mock<IConfigurationRepository>();
_builder = new KeyValueConfigurationBuilder(_mockRepo.Object);
}
[TestMethod]
public async Task LoadConfiguration_WithValidData_BuildsCorrectly()
{
// Arrange
var configurations = new List<AppConfiguration>
{
new AppConfiguration
{
Key = "Email:Templates:Welcome",
Value = @"{""subject"":""Welcome"",""sender"":""test@company.com""}",
ValidFrom = DateTime.UtcNow.AddDays(-1),
ExpiresAt = DateTime.UtcNow.AddDays(1)
}
};
_mockRepo.Setup(r => r.GetActiveConfigurations())
.ReturnsAsync(configurations);
// Act
await _builder.LoadConfiguration();
var config = _builder.Build();
// Assert
Assert.IsNotNull(config);
Assert.AreEqual("Welcome", config["Email:Templates:Welcome:subject"]);
}
[TestMethod]
public async Task LoadConfiguration_WithExpiredData_NotIncluded()
{
// Arrange
var configurations = new List<AppConfiguration>
{
new AppConfiguration
{
Key = "Test:Key",
Value = @"{""value"":""test""}",
ValidFrom = DateTime.UtcNow.AddDays(1),
ExpiresAt = DateTime.UtcNow.AddDays(2)
}
};
_mockRepo.Setup(r => r.GetActiveConfigurations())
.ReturnsAsync(new List<AppConfiguration>());
// Act
await _builder.LoadConfiguration();
var config = _builder.Build();
// Assert
Assert.IsNull(config["Test:Key:value"]);
}
[TestMethod]
public void AddKeyValue_WithNestedKeys_BuildsCorrectHierarchy()
{
// Arrange
var key = "Level1:Level2:Level3";
var value = @"{""setting"":""value""}";
// Act
_builder.AddKeyValue(key, value);
var config = _builder.Build();
// Assert
Assert.AreEqual("value", config["Level1:Level2:Level3:setting"]);
}
[TestMethod]
public async Task GetSection_ReturnsCorrectSubSection()
{
// Arrange
var configurations = new List<AppConfiguration>
{
new AppConfiguration
{
Key = "Parent:Child",
Value = @"{""setting"":""value""}",
ValidFrom = DateTime.UtcNow.AddDays(-1)
}
};
_mockRepo.Setup(r => r.GetActiveConfigurations())
.ReturnsAsync(configurations);
// Act
await _builder.LoadConfiguration();
var config = _builder.Build();
var section = config.GetSection("Parent");
// Assert
Assert.AreEqual("value", section["Child:setting"]);
}
[TestMethod]
public void Configuration_ShouldMapToTypedClass()
{
// Arrange
var configData = new JObject
{
["Email"] = new JObject
{
["Templates"] = new JObject
{
["Welcome"] = new JObject
{
["subject"] = "Welcome to our service",
["template"] = "welcome-template.html",
["sender"] = "noreply@test.com",
["settings"] = new JObject
{
["isEnabled"] = true,
["retryCount"] = 3
}
}
}
}
};
Microsoft.Extensions.Configuration.IConfiguration configuration = null;// new JsonConfiguration(configData, new Microsoft.Extensions.Configuration.ConfigurationReloadToken());
// Act
var welcomeConfig = configuration.GetSection("Email:Templates:Welcome").Get<WelcomeEmailConfig>();
// Assert
Assert.IsNotNull(welcomeConfig);
Assert.AreEqual("Welcome to our service", welcomeConfig.Subject);
Assert.AreEqual("welcome-template.html", welcomeConfig.Template);
Assert.AreEqual("noreply@test.com", welcomeConfig.Sender);
Assert.IsTrue(welcomeConfig.Settings.IsEnabled);
Assert.AreEqual(3, welcomeConfig.Settings.RetryCount);
}
public class WelcomeEmailConfig
{
public string Subject { get; set; }
public string Template { get; set; }
public string Sender { get; set; }
public EmailSettings Settings { get; set; }
}
public class EmailSettings
{
public bool IsEnabled { get; set; }
public int RetryCount { get; set; }
}
}

View file

@ -1,97 +1,84 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using Autofac; using Autofac;
using Core.Configurations;
using Core.ModuleRegistry; using Core.ModuleRegistry;
using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting; using Core.Configurations.JsonConfigProvider;
namespace Tests namespace Tests
{ {
/// <summary> /// <summary>
/// Act as base class for tests. Avoids duplication of test setup code /// Act as base class for tests. Avoids duplication of test setup code
/// </summary> /// </summary>
[TestClass] [TestClass]
public abstract partial class TestFixture public abstract partial class TestFixture
{ {
protected IContainer Container { get; private set; } protected IContainer Container { get; private set; }
protected ContainerBuilder ContainerBuilder { get; private set; } protected ContainerBuilder ContainerBuilder { get; private set; }
public virtual IConfigurationRoot Configuration()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appconfiguration.dev.json")
.Build();
return configuration;
}
/// <summary>
/// Should not be overriden. Rather override PreArrangeAll to setup data needed for a test class.
/// Override PrebuildContainer with a method that does nothing to prevent early build of IOC container
/// </summary>
[TestInitialize]
public void Setup()
{
CreateContainerBuilder();
Container = ContainerBuilder.Build();
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
}
[AssemblyInitialize] protected virtual void CreateContainerBuilder()
public static void AssemblySetup(TestContext tc) {
{ IConfigurationRoot configuration = Configuration();
Trace.Listeners.Add(new TextWriterTraceListener(Console.Out));
var envConfiguration = new ConfigurationBuilder() var builder = new ContainerBuilder();
.AddEnvironmentVariables() builder.RegisterInstance(new LoggerFactory())
.Build(); .As<ILoggerFactory>();
} builder.RegisterGeneric(typeof(Logger<>))
.As(typeof(ILogger<>))
.SingleInstance();
public virtual IConfigurationRoot Configuration() builder.RegisterModule(new Core.ModuleRegistry.DbPostgreSqlModule
{ {
ConnectionString = configuration.GetConnectionString("DefaultConnection")
});
IConfigurationBuilder configBuilder = Core.Configurations.AzureConfigurationManager.AppConfigBuilder("appsettings.dev.json"); builder.RegisterModule(new Core.ModuleRegistry.TelemetryModule
IConfigurationRoot configuration = configBuilder.Build(); {
TelemetryConfig = configuration.GetSection("ApplicationInsights").ToObject<Core.ModuleRegistry.TelemetryConfig>()
return configuration; });
}
/// <summary>
/// Should not be overriden. Rather override PreArrangeAll to setup data needed for a test class.
/// Override PrebuildContainer with a method that does nothing to prevent early build of IOC container
/// </summary>
[TestInitialize]
public void Setup()
{
CreateContainerBuilder();
Container = ContainerBuilder.Build();
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
}
protected virtual void CreateContainerBuilder()
{
IConfigurationRoot configuration = Configuration();
var builder = new ContainerBuilder();
builder.RegisterInstance(new LoggerFactory())
.As<ILoggerFactory>();
builder.RegisterGeneric(typeof(Logger<>))
.As(typeof(ILogger<>))
.SingleInstance();
builder.RegisterModule(new Core.ModuleRegistry.DbPostgreSqlModule
{
ConnectionString = configuration.GetConnectionString("ptdb")
});
builder.RegisterModule(new Core.ModuleRegistry.TelemetryModule
{
TelemetryConfig = configuration.GetSection("ApplicationInsights").Get<Core.ModuleRegistry.TelemetryConfig>()
});
ContainerBuilder = builder; ContainerBuilder = builder;
} }
[TestCleanup] [TestCleanup]
public void CleanUp() public void CleanUp()
{ {
Trace.Flush(); Trace.Flush();
var telemetryClient = Container.Resolve<TelemetryClient>(); var telemetryClient = Container.Resolve<TelemetryClient>();
telemetryClient.Flush(); telemetryClient.Flush();
if (Container != null) if (Container != null)
{ {
Container.Dispose(); Container.Dispose();
Container = null; Container = null;
} }
} }
} }
} }

View file

@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" /> <PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="moq" Version="4.20.72" /> <PackageReference Include="moq" Version="4.20.72" />
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" /> <PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
@ -26,7 +27,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.dev.json"> <None Update="appconfiguration.dev.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
</ItemGroup> </ItemGroup>

View file

@ -1,56 +0,0 @@
using Autofac;
using System.Data;
using Insight.Database;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Extensions.Configuration;
using Core.Configurations.PostgresqlConfigurationBuilder;
namespace Tests
{
[TestClass]
public class UnitTest1 : TestFixture
{
[TestMethod]
public void TestDefaultConnection()
{
var conn = Container.Resolve<IDbConnection>();
//https://www.reddit.com/r/dotnet/comments/6wdoyn/how_to_properly_register_dapper_on_net_core_2_di/
//https://www.code4it.dev/blog/postgres-crud-dapper/
//https://stackoverflow.com/questions/69169247/how-to-create-idbconnection-factory-using-autofac-for-dapper
conn.ExecuteSql("SELECT 1 as p");
//var sql = "SELECT * FROM swp.foo";
//var customers = conn.Query(sql, commandType:CommandType.Text);
}
[TestMethod]
public void TryTenantSetupService()
{
var conn = Container.Resolve<IDbConnection>();
}
[TestMethod]
public async Task TryDbSetup()
{
var conn = Container.Resolve<IDbConnection>();
var dbSetup = new Database.AppConfigurationSystem.ConfigurationDatabaseSetup(conn);
//await dbSetup..CreateDatabaseWithSchema("swp");
}
[TestMethod]
public void SetupPostgresql_LISTEN()
{
var builder = new ConfigurationBuilder()
.AddPostgresConfiguration(options =>
{
options.ConnectionString = "Host=192.168.1.57;Database=ptdb01;Username=postgres;Password=3911";
options.Channel = "config_changes";
options.ConfigurationQuery = @"select * from dev.app_configuration";
});
}
}
}

View file

@ -0,0 +1,32 @@
{
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=192.168.1.57;Port=5432;Database=ptdb01;User Id=sathumper;Password=3911;"
},
"ApplicationInsights": {
"ConnectionString": "InstrumentationKey=6d2e76ee-5343-4691-a5e3-81add43cb584;IngestionEndpoint=https://northeurope-0.in.applicationinsights.azure.com/"
},
"Feature": {
"Enabled": true,
"RolloutPercentage": 25,
"AllowedUserGroups": [ "beta" ]
},
"AnotherSetting": {
"Thresholds": {
"High": "123",
"Low": "-1"
},
"Temperature": {
"Indoor": {
"Max": { "Limit": 22 },
"Min": { "Limit": 18 }
},
"Outdoor": {
"Max": { "Limit": 12 },
"Min": { "Limit": 9 }
}
}
}
}

View file

@ -1,9 +0,0 @@
{
"AllowedHosts": "*",
"ConnectionStrings": {
"ptdb": "Host=192.168.1.57;Port=5432;Database=ptdb01;User Id=postgres;Password=3911;"
},
"ApplicationInsights": {
"ConnectionString": "InstrumentationKey=6d2e76ee-5343-4691-a5e3-81add43cb584;IngestionEndpoint=https://northeurope-0.in.applicationinsights.azure.com/"
}
}