156 lines
5.5 KiB
C#
156 lines
5.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using RudderSdk.Core.Models.RemoteConfig;
|
|
|
|
namespace RudderSdk.Core;
|
|
|
|
/// <summary>
|
|
/// Remote configuration values. <see cref="LoadAsync"/> caches every config
|
|
/// whose "active" flag is not explicitly false; <see cref="Get{T}"/> reads
|
|
/// typed values from the cache.
|
|
/// </summary>
|
|
public sealed class RemoteConfigService
|
|
{
|
|
private readonly RudderClient _client;
|
|
private readonly Dictionary<string, RemoteConfig> _cache = new();
|
|
private bool _isLoaded;
|
|
|
|
internal RemoteConfigService(RudderClient client) => _client = client;
|
|
|
|
/// <summary>True after the first successful <see cref="LoadAsync"/>.</summary>
|
|
public bool IsLoaded => _isLoaded;
|
|
|
|
/// <summary>The cached configs keyed by config key.</summary>
|
|
public IReadOnlyDictionary<string, RemoteConfig> Configs => _cache;
|
|
|
|
/// <summary>Fetches all configs and rebuilds the cache.</summary>
|
|
public async Task<IReadOnlyDictionary<string, RemoteConfig>> LoadAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var response = await _client.SendAsync<RawListRemoteConfigsResponse>(
|
|
"GET",
|
|
"/sdk/v1/remote-configs",
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
_cache.Clear();
|
|
if (response?.Configs != null)
|
|
{
|
|
foreach (var entry in response.Configs)
|
|
{
|
|
// Include everything except an explicit "active": false — a missing
|
|
// flag means active, matching the other SDKs.
|
|
if (entry.Value?["active"]?.Value<bool?>() == false)
|
|
continue;
|
|
|
|
var config = entry.Value?.ToObject<RemoteConfig>();
|
|
if (config != null)
|
|
_cache[config.Key ?? entry.Key] = config;
|
|
}
|
|
}
|
|
|
|
_isLoaded = true;
|
|
return _cache;
|
|
}
|
|
|
|
/// <summary>Fetches one config straight from the server, bypassing the cache.</summary>
|
|
public async Task<RemoteConfig> GetConfigAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _client.SendAsync<RemoteConfig>(
|
|
"GET",
|
|
"/sdk/v1/remote-configs/" + Url.Encode(key),
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Reads a typed value from the cache; returns the default when missing or unparsable.</summary>
|
|
public T Get<T>(string key, T defaultValue = default!)
|
|
{
|
|
if (!_isLoaded || string.IsNullOrEmpty(key) || !_cache.TryGetValue(key, out var config))
|
|
return defaultValue;
|
|
|
|
return ParseValue(config.Value, config.ValueType, defaultValue);
|
|
}
|
|
|
|
/// <summary>Loads the cache on first use, then reads a typed value.</summary>
|
|
public async Task<T> GetAsync<T>(string key, T defaultValue = default!, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_isLoaded)
|
|
await LoadAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return Get(key, defaultValue);
|
|
}
|
|
|
|
internal void ApplyOverride(string key, string? value, string valueType = "json")
|
|
{
|
|
if (string.IsNullOrEmpty(key)) return;
|
|
_cache[key] = new RemoteConfig
|
|
{
|
|
Key = key,
|
|
// The generated model declares Value non-nullable; a JSON null patch
|
|
// value is still legal and ParseValue maps it to the caller's default.
|
|
Value = value!,
|
|
ValueType = valueType,
|
|
Active = true
|
|
};
|
|
_isLoaded = true;
|
|
}
|
|
|
|
private static T ParseValue<T>(string value, string valueType, T defaultValue)
|
|
{
|
|
if (value == null) return defaultValue;
|
|
|
|
try
|
|
{
|
|
if (typeof(T) == typeof(string))
|
|
return (T)(object)value;
|
|
|
|
switch ((valueType ?? string.Empty).ToLowerInvariant())
|
|
{
|
|
case "number":
|
|
case "int":
|
|
case "integer":
|
|
case "float":
|
|
case "double":
|
|
return (T)ConvertNumber(value, typeof(T));
|
|
case "bool":
|
|
case "boolean":
|
|
return (T)(object)bool.Parse(value);
|
|
case "json":
|
|
case "object":
|
|
return JsonConvert.DeserializeObject<T>(value) ?? defaultValue;
|
|
default:
|
|
return (T)System.Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
private static object ConvertNumber(string value, Type target)
|
|
{
|
|
if (target == typeof(float))
|
|
return float.Parse(value, CultureInfo.InvariantCulture);
|
|
if (target == typeof(double))
|
|
return double.Parse(value, CultureInfo.InvariantCulture);
|
|
if (target == typeof(long))
|
|
return long.Parse(value, CultureInfo.InvariantCulture);
|
|
if (target == typeof(decimal))
|
|
return decimal.Parse(value, CultureInfo.InvariantCulture);
|
|
return int.Parse(value, CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
// Raw shape of the list payload: the generated RemoteConfig.Active is a
|
|
// non-nullable bool and cannot distinguish "active": false from a missing
|
|
// flag, so the active check runs on the raw JSON before mapping.
|
|
private sealed class RawListRemoteConfigsResponse
|
|
{
|
|
[JsonProperty("configs")]
|
|
public Dictionary<string, JObject>? Configs { get; set; }
|
|
}
|
|
}
|