Initial commit
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user