| | | 1 | | using ArturRios.Configuration.Enums; |
| | | 2 | | using DotNetEnv; |
| | | 3 | | using Microsoft.Extensions.Configuration; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Configuration.Loaders; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Provides helper methods to load environment variables from <c>.env</c> files and |
| | | 10 | | /// application settings from <c>appsettings.<Environment>.json</c> files. |
| | | 11 | | /// </summary> |
| | | 12 | | /// <remarks> |
| | | 13 | | /// <para> |
| | | 14 | | /// By default, it looks for files under the <c>Environments</c> and <c>Settings</c> folders within the application base |
| | | 15 | | /// If a specific environment file is not found, it falls back to the <see cref="EnvironmentType.Local"/> environment. |
| | | 16 | | /// </para> |
| | | 17 | | /// <para> |
| | | 18 | | /// File names are matched without regard to case, so <c>.env.Development</c> and <c>.env.development</c> — |
| | | 19 | | /// or <c>appsettings.Local.json</c> and <c>appsettings.local.json</c> — are equally acceptable on every |
| | | 20 | | /// platform. Matching by exact case only would resolve on Windows and silently find nothing on Linux. |
| | | 21 | | /// </para> |
| | | 22 | | /// </remarks> |
| | | 23 | | public class ConfigurationLoader |
| | | 24 | | { |
| | | 25 | | /// <summary> |
| | | 26 | | /// Default environment name used when a specific environment file cannot be found. |
| | | 27 | | /// </summary> |
| | | 28 | | private const string DefaultEnvironmentName = nameof(EnvironmentType.Local); |
| | | 29 | | /// <summary> |
| | | 30 | | /// Default folder name that contains <c>.env</c> files. |
| | | 31 | | /// </summary> |
| | | 32 | | private const string DefaultEnvFileFolder = "Environments"; |
| | | 33 | | /// <summary> |
| | | 34 | | /// Default folder name that contains <c>appsettings</c> files. |
| | | 35 | | /// </summary> |
| | | 36 | | private const string DefaultAppSettingsFolder = "Settings"; |
| | | 37 | | |
| | | 38 | | /// <summary> |
| | | 39 | | /// One console logger factory for the whole process. Creating a factory per loader leaked one provider |
| | | 40 | | /// per instance, since nothing ever disposed them. |
| | | 41 | | /// </summary> |
| | 1 | 42 | | private static readonly Lazy<ILoggerFactory> FallbackLoggerFactory = |
| | 3 | 43 | | new(() => LoggerFactory.Create(builder => builder.AddConsole()), isThreadSafe: true); |
| | | 44 | | |
| | | 45 | | private readonly string _basePath; |
| | | 46 | | private readonly IConfigurationBuilder? _configurationBuilder; |
| | | 47 | | private readonly string _environmentName; |
| | | 48 | | private readonly ILogger<ConfigurationLoader> _logger; |
| | | 49 | | |
| | | 50 | | /// <summary> |
| | | 51 | | /// Initializes a new instance of <see cref="ConfigurationLoader"/> using an existing <see cref="IConfigurationBuild |
| | | 52 | | /// </summary> |
| | | 53 | | /// <param name="configurationBuilder">The configuration builder where JSON files will be added.</param> |
| | | 54 | | /// <param name="environmentName">The environment name (e.g., <c>Local</c>, <c>Development</c>, <c>Staging</c>, <c>P |
| | | 55 | | /// <param name="basePath">Optional base path to search for files. If not provided, the current domain base director |
| | | 56 | | /// <param name="logger">Optional logger instance. If not provided, a shared console logger is used.</param> |
| | | 57 | | /// <exception cref="ArgumentNullException"><paramref name="configurationBuilder"/> is <c>null</c>.</exception> |
| | | 58 | | /// <exception cref="ArgumentException"><paramref name="environmentName"/> is <c>null</c> or whitespace.</exception> |
| | | 59 | | public ConfigurationLoader(IConfigurationBuilder configurationBuilder, string environmentName, |
| | | 60 | | string? basePath = null, ILogger<ConfigurationLoader>? logger = null) |
| | 15 | 61 | | : this(environmentName, basePath, logger) |
| | | 62 | | { |
| | 12 | 63 | | ArgumentNullException.ThrowIfNull(configurationBuilder); |
| | | 64 | | |
| | 11 | 65 | | _configurationBuilder = configurationBuilder; |
| | 11 | 66 | | } |
| | | 67 | | |
| | | 68 | | /// <summary> |
| | | 69 | | /// Initializes a new instance of <see cref="ConfigurationLoader"/> without a configuration builder. |
| | | 70 | | /// </summary> |
| | | 71 | | /// <param name="environmentName">The environment name (e.g., <c>Local</c>, <c>Development</c>, <c>Staging</c>, <c>P |
| | | 72 | | /// <param name="basePath">Optional base path to search for files. If not provided, the current domain base director |
| | | 73 | | /// <param name="logger">Optional logger instance. If not provided, a shared console logger is used.</param> |
| | | 74 | | /// <exception cref="ArgumentException"><paramref name="environmentName"/> is <c>null</c> or whitespace.</exception> |
| | | 75 | | /// <remarks> |
| | | 76 | | /// Use <see cref="LoadEnvironment"/> with this constructor. <see cref="LoadAppSettings"/> requires an <see cref="IC |
| | | 77 | | /// </remarks> |
| | 28 | 78 | | public ConfigurationLoader(string environmentName, string? basePath = null, |
| | 28 | 79 | | ILogger<ConfigurationLoader>? logger = null) |
| | | 80 | | { |
| | 28 | 81 | | ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); |
| | | 82 | | |
| | 22 | 83 | | _environmentName = environmentName; |
| | 22 | 84 | | _basePath = string.IsNullOrEmpty(basePath) ? AppDomain.CurrentDomain.BaseDirectory : basePath; |
| | 22 | 85 | | _logger = logger ?? FallbackLoggerFactory.Value.CreateLogger<ConfigurationLoader>(); |
| | 22 | 86 | | } |
| | | 87 | | |
| | | 88 | | /// <summary> |
| | | 89 | | /// Loads environment variables from a specific <c>.env</c> file based on the configured environment. |
| | | 90 | | /// </summary> |
| | | 91 | | /// <remarks> |
| | | 92 | | /// It looks for <c>Environments/.env.<environment></c> and falls back to <c>Environments/.env.local</c> if no |
| | | 93 | | /// The file name is matched without regard to case. |
| | | 94 | | /// </remarks> |
| | | 95 | | public void LoadEnvironment() |
| | | 96 | | { |
| | 9 | 97 | | var envFolder = Path.Combine(_basePath, DefaultEnvFileFolder); |
| | 9 | 98 | | var envFile = ResolveFile(envFolder, $".env.{_environmentName}"); |
| | 9 | 99 | | var defaultEnvFile = ResolveFile(envFolder, $".env.{DefaultEnvironmentName}"); |
| | | 100 | | |
| | 9 | 101 | | if (envFile is not null) |
| | | 102 | | { |
| | 3 | 103 | | _logger.LogInformation("Loading variables for {EnvironmentName} environment...", _environmentName); |
| | | 104 | | |
| | 3 | 105 | | Env.Load(envFile); |
| | | 106 | | } |
| | 6 | 107 | | else if (defaultEnvFile is not null) |
| | | 108 | | { |
| | 4 | 109 | | _logger.LogInformation( |
| | 4 | 110 | | "Could not find variables for {EnvironmentName} environment. Loading default environment {DefaultEnviron |
| | 4 | 111 | | _environmentName, DefaultEnvironmentName); |
| | | 112 | | |
| | 4 | 113 | | Env.Load(defaultEnvFile); |
| | | 114 | | } |
| | | 115 | | else |
| | | 116 | | { |
| | 2 | 117 | | _logger.LogInformation("Could not find any environment variables"); |
| | | 118 | | } |
| | 2 | 119 | | } |
| | | 120 | | |
| | | 121 | | /// <summary> |
| | | 122 | | /// Loads JSON application settings for the configured environment into the provided <see cref="IConfigurationBuilde |
| | | 123 | | /// </summary> |
| | | 124 | | /// <exception cref="InvalidOperationException">Thrown when no <see cref="IConfigurationBuilder"/> was provided in t |
| | | 125 | | /// <remarks> |
| | | 126 | | /// It looks for <c>Settings/appsettings.<environment>.json</c> and falls back to <c>Settings/appsettings.loca |
| | | 127 | | /// The file name is matched without regard to case. |
| | | 128 | | /// </remarks> |
| | | 129 | | public void LoadAppSettings() |
| | | 130 | | { |
| | 12 | 131 | | if (_configurationBuilder is null) |
| | | 132 | | { |
| | 1 | 133 | | throw new InvalidOperationException( |
| | 1 | 134 | | "Cannot load appsettings.json if configuration builder is not provided on constructor"); |
| | | 135 | | } |
| | | 136 | | |
| | 11 | 137 | | var settingsFolder = Path.Combine(_basePath, DefaultAppSettingsFolder); |
| | 11 | 138 | | var envSettingsFile = ResolveFile(settingsFolder, $"appsettings.{_environmentName}.json"); |
| | 11 | 139 | | var defaultSettingsFile = ResolveFile(settingsFolder, $"appsettings.{DefaultEnvironmentName}.json"); |
| | | 140 | | |
| | 11 | 141 | | if (envSettingsFile is not null) |
| | | 142 | | { |
| | 5 | 143 | | _logger.LogInformation("Loading app settings for {EnvironmentName} environment...", _environmentName); |
| | | 144 | | |
| | 5 | 145 | | _configurationBuilder.AddJsonFile(envSettingsFile, false, true); |
| | | 146 | | } |
| | 6 | 147 | | else if (defaultSettingsFile is not null) |
| | | 148 | | { |
| | 4 | 149 | | _logger.LogInformation( |
| | 4 | 150 | | "Could not find app settings for {EnvironmentName} environment. Loading default environment {DefaultEnvi |
| | 4 | 151 | | _environmentName, DefaultEnvironmentName); |
| | | 152 | | |
| | 4 | 153 | | _configurationBuilder.AddJsonFile(defaultSettingsFile, false, true); |
| | | 154 | | } |
| | | 155 | | else |
| | | 156 | | { |
| | 2 | 157 | | _logger.LogInformation("Could not find any app settings"); |
| | | 158 | | } |
| | 2 | 159 | | } |
| | | 160 | | |
| | | 161 | | /// <summary> |
| | | 162 | | /// Finds <paramref name="fileName"/> inside <paramref name="folder"/>, ignoring case. |
| | | 163 | | /// </summary> |
| | | 164 | | /// <returns>The full path of the matching file, or <c>null</c> when the folder or the file is absent.</returns> |
| | | 165 | | /// <remarks> |
| | | 166 | | /// The exact-case path is probed first, which is the common case and costs a single stat call. Only when |
| | | 167 | | /// that misses is the directory enumerated, so a case-insensitive file system never pays for the scan. |
| | | 168 | | /// </remarks> |
| | | 169 | | private static string? ResolveFile(string folder, string fileName) |
| | | 170 | | { |
| | 40 | 171 | | var exact = Path.Combine(folder, fileName); |
| | | 172 | | |
| | 40 | 173 | | if (File.Exists(exact)) |
| | | 174 | | { |
| | 8 | 175 | | return exact; |
| | | 176 | | } |
| | | 177 | | |
| | 32 | 178 | | if (!Directory.Exists(folder)) |
| | | 179 | | { |
| | 8 | 180 | | return null; |
| | | 181 | | } |
| | | 182 | | |
| | 87 | 183 | | foreach (var candidate in Directory.EnumerateFiles(folder)) |
| | | 184 | | { |
| | 25 | 185 | | if (string.Equals(Path.GetFileName(candidate), fileName, StringComparison.OrdinalIgnoreCase)) |
| | | 186 | | { |
| | 11 | 187 | | return candidate; |
| | | 188 | | } |
| | | 189 | | } |
| | | 190 | | |
| | 13 | 191 | | return null; |
| | 11 | 192 | | } |
| | | 193 | | } |