feat: add config processor mapping parsed config to NatsOptions
Port of Go server/opts.go processConfigFileLine switch. Maps parsed NATS config dictionaries to NatsOptions fields including: - Core options (port, host, server_name, limits, ping, write_deadline) - Logging (debug, trace, logfile, log rotation) - Authorization (single user, users array with permissions) - TLS (cert/key/ca, verify, pinned_certs, handshake_first) - Monitoring (http_port, https_port, http/https listen, base_path) - Lifecycle (lame_duck_duration/grace_period) - Server tags, file paths, system account options Includes error collection (not fail-fast), duration parsing (ms/s/m/h strings and numeric seconds), host:port listen parsing, and 56 tests covering all config sections plus validation edge cases.
This commit is contained in:
685
src/NATS.Server/Configuration/ConfigProcessor.cs
Normal file
685
src/NATS.Server/Configuration/ConfigProcessor.cs
Normal file
@@ -0,0 +1,685 @@
|
||||
// Port of Go server/opts.go processConfigFileLine — maps parsed config dictionaries
|
||||
// to NatsOptions. Reference: golang/nats-server/server/opts.go lines 1050-1400.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a parsed NATS configuration dictionary (produced by <see cref="NatsConfParser"/>)
|
||||
/// into a fully populated <see cref="NatsOptions"/> instance. Collects all validation
|
||||
/// errors rather than failing on the first one.
|
||||
/// </summary>
|
||||
public static class ConfigProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a configuration file and returns the populated options.
|
||||
/// </summary>
|
||||
public static NatsOptions ProcessConfigFile(string filePath)
|
||||
{
|
||||
var config = NatsConfParser.ParseFile(filePath);
|
||||
var opts = new NatsOptions { ConfigFile = filePath };
|
||||
ApplyConfig(config, opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses configuration text (not from a file) and returns the populated options.
|
||||
/// </summary>
|
||||
public static NatsOptions ProcessConfig(string configText)
|
||||
{
|
||||
var config = NatsConfParser.Parse(configText);
|
||||
var opts = new NatsOptions();
|
||||
ApplyConfig(config, opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a parsed configuration dictionary to existing options.
|
||||
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
|
||||
/// </summary>
|
||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessKey(key, value, opts, errors);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Error processing '{key}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ConfigProcessorException("Configuration errors", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
|
||||
// but we normalize here for the switch statement.
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "listen":
|
||||
ParseListen(value, opts);
|
||||
break;
|
||||
case "port":
|
||||
opts.Port = ToInt(value);
|
||||
break;
|
||||
case "host" or "net":
|
||||
opts.Host = ToString(value);
|
||||
break;
|
||||
case "server_name":
|
||||
var name = ToString(value);
|
||||
if (name.Contains(' '))
|
||||
errors.Add("server_name cannot contain spaces");
|
||||
else
|
||||
opts.ServerName = name;
|
||||
break;
|
||||
case "client_advertise":
|
||||
opts.ClientAdvertise = ToString(value);
|
||||
break;
|
||||
|
||||
// Logging
|
||||
case "debug":
|
||||
opts.Debug = ToBool(value);
|
||||
break;
|
||||
case "trace":
|
||||
opts.Trace = ToBool(value);
|
||||
break;
|
||||
case "trace_verbose":
|
||||
opts.TraceVerbose = ToBool(value);
|
||||
if (opts.TraceVerbose)
|
||||
opts.Trace = true;
|
||||
break;
|
||||
case "logtime":
|
||||
opts.Logtime = ToBool(value);
|
||||
break;
|
||||
case "logtime_utc":
|
||||
opts.LogtimeUTC = ToBool(value);
|
||||
break;
|
||||
case "logfile" or "log_file":
|
||||
opts.LogFile = ToString(value);
|
||||
break;
|
||||
case "log_size_limit":
|
||||
opts.LogSizeLimit = ToLong(value);
|
||||
break;
|
||||
case "log_max_num":
|
||||
opts.LogMaxFiles = ToInt(value);
|
||||
break;
|
||||
case "syslog":
|
||||
opts.Syslog = ToBool(value);
|
||||
break;
|
||||
case "remote_syslog":
|
||||
opts.RemoteSyslog = ToString(value);
|
||||
break;
|
||||
|
||||
// Limits
|
||||
case "max_payload":
|
||||
opts.MaxPayload = ToInt(value);
|
||||
break;
|
||||
case "max_control_line":
|
||||
opts.MaxControlLine = ToInt(value);
|
||||
break;
|
||||
case "max_connections" or "max_conn":
|
||||
opts.MaxConnections = ToInt(value);
|
||||
break;
|
||||
case "max_pending":
|
||||
opts.MaxPending = ToLong(value);
|
||||
break;
|
||||
case "max_subs" or "max_subscriptions":
|
||||
opts.MaxSubs = ToInt(value);
|
||||
break;
|
||||
case "max_sub_tokens" or "max_subscription_tokens":
|
||||
var tokens = ToInt(value);
|
||||
if (tokens > 256)
|
||||
errors.Add("max_sub_tokens cannot exceed 256");
|
||||
else
|
||||
opts.MaxSubTokens = tokens;
|
||||
break;
|
||||
case "max_traced_msg_len":
|
||||
opts.MaxTracedMsgLen = ToInt(value);
|
||||
break;
|
||||
case "max_closed_clients":
|
||||
opts.MaxClosedClients = ToInt(value);
|
||||
break;
|
||||
case "disable_sublist_cache" or "no_sublist_cache":
|
||||
opts.DisableSublistCache = ToBool(value);
|
||||
break;
|
||||
case "write_deadline":
|
||||
opts.WriteDeadline = ParseDuration(value);
|
||||
break;
|
||||
|
||||
// Ping
|
||||
case "ping_interval":
|
||||
opts.PingInterval = ParseDuration(value);
|
||||
break;
|
||||
case "ping_max" or "ping_max_out":
|
||||
opts.MaxPingsOut = ToInt(value);
|
||||
break;
|
||||
|
||||
// Monitoring
|
||||
case "http_port" or "monitor_port":
|
||||
opts.MonitorPort = ToInt(value);
|
||||
break;
|
||||
case "https_port":
|
||||
opts.MonitorHttpsPort = ToInt(value);
|
||||
break;
|
||||
case "http":
|
||||
ParseMonitorListen(value, opts, isHttps: false);
|
||||
break;
|
||||
case "https":
|
||||
ParseMonitorListen(value, opts, isHttps: true);
|
||||
break;
|
||||
case "http_base_path":
|
||||
opts.MonitorBasePath = ToString(value);
|
||||
break;
|
||||
|
||||
// Lifecycle
|
||||
case "lame_duck_duration":
|
||||
opts.LameDuckDuration = ParseDuration(value);
|
||||
break;
|
||||
case "lame_duck_grace_period":
|
||||
opts.LameDuckGracePeriod = ParseDuration(value);
|
||||
break;
|
||||
|
||||
// Files
|
||||
case "pidfile" or "pid_file":
|
||||
opts.PidFile = ToString(value);
|
||||
break;
|
||||
case "ports_file_dir":
|
||||
opts.PortsFileDir = ToString(value);
|
||||
break;
|
||||
|
||||
// Auth
|
||||
case "authorization":
|
||||
if (value is Dictionary<string, object?> authDict)
|
||||
ParseAuthorization(authDict, opts, errors);
|
||||
break;
|
||||
case "no_auth_user":
|
||||
opts.NoAuthUser = ToString(value);
|
||||
break;
|
||||
|
||||
// TLS
|
||||
case "tls":
|
||||
if (value is Dictionary<string, object?> tlsDict)
|
||||
ParseTls(tlsDict, opts, errors);
|
||||
break;
|
||||
case "allow_non_tls":
|
||||
opts.AllowNonTls = ToBool(value);
|
||||
break;
|
||||
|
||||
// Tags
|
||||
case "server_tags":
|
||||
if (value is Dictionary<string, object?> tagsDict)
|
||||
ParseTags(tagsDict, opts);
|
||||
break;
|
||||
|
||||
// Profiling
|
||||
case "prof_port":
|
||||
opts.ProfPort = ToInt(value);
|
||||
break;
|
||||
|
||||
// System account
|
||||
case "system_account":
|
||||
opts.SystemAccount = ToString(value);
|
||||
break;
|
||||
case "no_system_account":
|
||||
opts.NoSystemAccount = ToBool(value);
|
||||
break;
|
||||
case "no_header_support":
|
||||
opts.NoHeaderSupport = ToBool(value);
|
||||
break;
|
||||
case "connect_error_reports":
|
||||
opts.ConnectErrorReports = ToInt(value);
|
||||
break;
|
||||
case "reconnect_error_reports":
|
||||
opts.ReconnectErrorReports = ToInt(value);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.)
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Listen parsing ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses a "listen" value that can be:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>":4222"</c> — port only</item>
|
||||
/// <item><c>"0.0.0.0:4222"</c> — host + port</item>
|
||||
/// <item><c>"4222"</c> — bare number (port only)</item>
|
||||
/// <item><c>4222</c> — integer (port only)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static void ParseListen(object? value, NatsOptions opts)
|
||||
{
|
||||
var (host, port) = ParseHostPort(value);
|
||||
if (host is not null)
|
||||
opts.Host = host;
|
||||
if (port is not null)
|
||||
opts.Port = port.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a monitor listen value. For "http" the port goes to MonitorPort;
|
||||
/// for "https" the port goes to MonitorHttpsPort.
|
||||
/// </summary>
|
||||
private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps)
|
||||
{
|
||||
var (host, port) = ParseHostPort(value);
|
||||
if (host is not null)
|
||||
opts.MonitorHost = host;
|
||||
if (port is not null)
|
||||
{
|
||||
if (isHttps)
|
||||
opts.MonitorHttpsPort = port.Value;
|
||||
else
|
||||
opts.MonitorPort = port.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared host:port parsing logic.
|
||||
/// </summary>
|
||||
private static (string? Host, int? Port) ParseHostPort(object? value)
|
||||
{
|
||||
if (value is long l)
|
||||
return (null, (int)l);
|
||||
|
||||
var str = ToString(value);
|
||||
|
||||
// Try bare integer
|
||||
if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort))
|
||||
return (null, barePort);
|
||||
|
||||
// Check for host:port
|
||||
var colonIdx = str.LastIndexOf(':');
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
var hostPart = str[..colonIdx];
|
||||
var portPart = str[(colonIdx + 1)..];
|
||||
if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||
{
|
||||
var host = hostPart.Length > 0 ? hostPart : null;
|
||||
return (host, p);
|
||||
}
|
||||
}
|
||||
|
||||
throw new FormatException($"Cannot parse listen value: '{str}'");
|
||||
}
|
||||
|
||||
// ─── Duration parsing ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses a duration value. Accepts:
|
||||
/// <list type="bullet">
|
||||
/// <item>A string with unit suffix: "30s", "2m", "1h", "500ms"</item>
|
||||
/// <item>A number (long/double) treated as seconds</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static TimeSpan ParseDuration(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
long seconds => TimeSpan.FromSeconds(seconds),
|
||||
double seconds => TimeSpan.FromSeconds(seconds),
|
||||
string s => ParseDurationString(s),
|
||||
_ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly Regex DurationPattern = new(
|
||||
@"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static TimeSpan ParseDurationString(string s)
|
||||
{
|
||||
var match = DurationPattern.Match(s);
|
||||
if (!match.Success)
|
||||
throw new FormatException($"Cannot parse duration: '{s}'");
|
||||
|
||||
var amount = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
var unit = match.Groups[2].Value.ToLowerInvariant();
|
||||
|
||||
return unit switch
|
||||
{
|
||||
"ms" => TimeSpan.FromMilliseconds(amount),
|
||||
"s" => TimeSpan.FromSeconds(amount),
|
||||
"m" => TimeSpan.FromMinutes(amount),
|
||||
"h" => TimeSpan.FromHours(amount),
|
||||
_ => throw new FormatException($"Unknown duration unit: '{unit}'"),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Authorization parsing ─────────────────────────────────────
|
||||
|
||||
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "user" or "username":
|
||||
opts.Username = ToString(value);
|
||||
break;
|
||||
case "pass" or "password":
|
||||
opts.Password = ToString(value);
|
||||
break;
|
||||
case "token":
|
||||
opts.Authorization = ToString(value);
|
||||
break;
|
||||
case "timeout":
|
||||
opts.AuthTimeout = value switch
|
||||
{
|
||||
long l => TimeSpan.FromSeconds(l),
|
||||
double d => TimeSpan.FromSeconds(d),
|
||||
string s => ParseDuration(s),
|
||||
_ => throw new FormatException($"Invalid auth timeout type: {value?.GetType().Name}"),
|
||||
};
|
||||
break;
|
||||
case "users":
|
||||
if (value is List<object?> userList)
|
||||
opts.Users = ParseUsers(userList, errors);
|
||||
break;
|
||||
default:
|
||||
// Unknown auth keys silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<User> ParseUsers(List<object?> list, List<string> errors)
|
||||
{
|
||||
var users = new List<User>();
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is not Dictionary<string, object?> userDict)
|
||||
{
|
||||
errors.Add("Expected user entry to be a map");
|
||||
continue;
|
||||
}
|
||||
|
||||
string? username = null;
|
||||
string? password = null;
|
||||
string? account = null;
|
||||
Permissions? permissions = null;
|
||||
|
||||
foreach (var (key, value) in userDict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "user" or "username":
|
||||
username = ToString(value);
|
||||
break;
|
||||
case "pass" or "password":
|
||||
password = ToString(value);
|
||||
break;
|
||||
case "account":
|
||||
account = ToString(value);
|
||||
break;
|
||||
case "permissions" or "permission":
|
||||
if (value is Dictionary<string, object?> permDict)
|
||||
permissions = ParsePermissions(permDict, errors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (username is null)
|
||||
{
|
||||
errors.Add("User entry missing 'user' field");
|
||||
continue;
|
||||
}
|
||||
|
||||
users.Add(new User
|
||||
{
|
||||
Username = username,
|
||||
Password = password ?? string.Empty,
|
||||
Account = account,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
private static Permissions ParsePermissions(Dictionary<string, object?> dict, List<string> errors)
|
||||
{
|
||||
SubjectPermission? publish = null;
|
||||
SubjectPermission? subscribe = null;
|
||||
ResponsePermission? response = null;
|
||||
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "publish" or "pub":
|
||||
publish = ParseSubjectPermission(value, errors);
|
||||
break;
|
||||
case "subscribe" or "sub":
|
||||
subscribe = ParseSubjectPermission(value, errors);
|
||||
break;
|
||||
case "resp" or "response":
|
||||
if (value is Dictionary<string, object?> respDict)
|
||||
response = ParseResponsePermission(respDict);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Permissions
|
||||
{
|
||||
Publish = publish,
|
||||
Subscribe = subscribe,
|
||||
Response = response,
|
||||
};
|
||||
}
|
||||
|
||||
private static SubjectPermission? ParseSubjectPermission(object? value, List<string> errors)
|
||||
{
|
||||
// Can be a simple list of strings (treated as allow) or a dict with allow/deny
|
||||
if (value is Dictionary<string, object?> dict)
|
||||
{
|
||||
IReadOnlyList<string>? allow = null;
|
||||
IReadOnlyList<string>? deny = null;
|
||||
|
||||
foreach (var (key, v) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "allow":
|
||||
allow = ToStringList(v);
|
||||
break;
|
||||
case "deny":
|
||||
deny = ToStringList(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new SubjectPermission { Allow = allow, Deny = deny };
|
||||
}
|
||||
|
||||
if (value is List<object?> list)
|
||||
{
|
||||
return new SubjectPermission { Allow = ToStringList(list) };
|
||||
}
|
||||
|
||||
if (value is string s)
|
||||
{
|
||||
return new SubjectPermission { Allow = [s] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ResponsePermission ParseResponsePermission(Dictionary<string, object?> dict)
|
||||
{
|
||||
var maxMsgs = 0;
|
||||
var expires = TimeSpan.Zero;
|
||||
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "max_msgs" or "max":
|
||||
maxMsgs = ToInt(value);
|
||||
break;
|
||||
case "expires" or "ttl":
|
||||
expires = ParseDuration(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires };
|
||||
}
|
||||
|
||||
// ─── TLS parsing ───────────────────────────────────────────────
|
||||
|
||||
private static void ParseTls(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "cert_file":
|
||||
opts.TlsCert = ToString(value);
|
||||
break;
|
||||
case "key_file":
|
||||
opts.TlsKey = ToString(value);
|
||||
break;
|
||||
case "ca_file":
|
||||
opts.TlsCaCert = ToString(value);
|
||||
break;
|
||||
case "verify":
|
||||
opts.TlsVerify = ToBool(value);
|
||||
break;
|
||||
case "verify_and_map":
|
||||
var map = ToBool(value);
|
||||
opts.TlsMap = map;
|
||||
if (map)
|
||||
opts.TlsVerify = true;
|
||||
break;
|
||||
case "timeout":
|
||||
opts.TlsTimeout = value switch
|
||||
{
|
||||
long l => TimeSpan.FromSeconds(l),
|
||||
double d => TimeSpan.FromSeconds(d),
|
||||
string s => ParseDuration(s),
|
||||
_ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"),
|
||||
};
|
||||
break;
|
||||
case "connection_rate_limit":
|
||||
opts.TlsRateLimit = ToLong(value);
|
||||
break;
|
||||
case "pinned_certs":
|
||||
if (value is List<object?> pinnedList)
|
||||
{
|
||||
var certs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in pinnedList)
|
||||
{
|
||||
if (item is string s)
|
||||
certs.Add(s.ToLowerInvariant());
|
||||
}
|
||||
|
||||
opts.TlsPinnedCerts = certs;
|
||||
}
|
||||
|
||||
break;
|
||||
case "handshake_first" or "first" or "immediate":
|
||||
opts.TlsHandshakeFirst = ToBool(value);
|
||||
break;
|
||||
case "handshake_first_fallback":
|
||||
opts.TlsHandshakeFirstFallback = ParseDuration(value);
|
||||
break;
|
||||
default:
|
||||
// Unknown TLS keys silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags parsing ──────────────────────────────────────────────
|
||||
|
||||
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
|
||||
{
|
||||
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
tags[key] = ToString(value);
|
||||
}
|
||||
|
||||
opts.Tags = tags;
|
||||
}
|
||||
|
||||
// ─── Type conversion helpers ───────────────────────────────────
|
||||
|
||||
private static int ToInt(object? value) => value switch
|
||||
{
|
||||
long l => (int)l,
|
||||
int i => i,
|
||||
double d => (int)d,
|
||||
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"),
|
||||
};
|
||||
|
||||
private static long ToLong(object? value) => value switch
|
||||
{
|
||||
long l => l,
|
||||
int i => i,
|
||||
double d => (long)d,
|
||||
string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"),
|
||||
};
|
||||
|
||||
private static bool ToBool(object? value) => value switch
|
||||
{
|
||||
bool b => b,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to bool"),
|
||||
};
|
||||
|
||||
private static string ToString(object? value) => value switch
|
||||
{
|
||||
string s => s,
|
||||
long l => l.ToString(CultureInfo.InvariantCulture),
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to string"),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<string> ToStringList(object? value)
|
||||
{
|
||||
if (value is List<object?> list)
|
||||
{
|
||||
var result = new List<string>(list.Count);
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is string s)
|
||||
result.Add(s);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
return [str];
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when one or more configuration validation errors are detected.
|
||||
/// All errors are collected rather than failing on the first one.
|
||||
/// </summary>
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors)
|
||||
: Exception(message)
|
||||
{
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
}
|
||||
Reference in New Issue
Block a user