diff --git a/GrowthBook/Api/Extensions/HttpClientExtensions.cs b/GrowthBook/Api/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..3162a7e --- /dev/null +++ b/GrowthBook/Api/Extensions/HttpClientExtensions.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using Newtonsoft.Json; +using System.Threading.Tasks; +using System.Threading; +using System.IO; +using GrowthBook.Extensions; +using Newtonsoft.Json.Linq; +using Microsoft.Extensions.Logging; +using System.Linq; + +namespace GrowthBook.Api.Extensions +{ + public static class HttpClientExtensions + { + private sealed class FeaturesResponse + { + public int FeatureCount => Features?.Count ?? 0; + public Dictionary Features { get; set; } + public string EncryptedFeatures { get; set; } + } + + public static async Task<(IDictionary Features, bool IsServerSentEventsEnabled)> GetFeaturesFrom(this HttpClient httpClient, string endpoint, ILogger logger, GrowthBookConfigurationOptions config, CancellationToken cancellationToken) + { + var response = await httpClient.GetAsync(endpoint, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger.LogError($"HTTP request to default Features API endpoint '{endpoint}' resulted in a {response.StatusCode} status code"); + return (null, false); + } + + var json = await response.Content.ReadAsStringAsync(); + + logger.LogDebug($"Read response JSON from default Features API request: '{json}'"); + + var isServerSentEventsEnabled = response.Headers.TryGetValues(HttpHeaders.ServerSentEvents.Key, out var values) && values.Contains(HttpHeaders.ServerSentEvents.EnabledValue); + + logger.LogDebug($"{nameof(FeatureRefreshWorker)} is configured to prefer server sent events and enabled is now '{isServerSentEventsEnabled}'"); + + var features = ParseFeaturesFrom(json, logger, config); + + return (features, isServerSentEventsEnabled); + } + + public static async Task UpdateWithFeaturesStreamFrom(this HttpClient httpClient, string endpoint, ILogger logger, GrowthBookConfigurationOptions config, CancellationToken cancellationToken, Func, Task> onFeaturesRetrieved) + { + var stream = await httpClient.GetStreamAsync(endpoint); + + using (var reader = new StreamReader(stream)) + { + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) + { + var json = reader.ReadLine(); + + // All server sent events will have the format ":" and each message + // is a single line in the stream. Right now, the only message that we care about + // has a key of "data" and value of the JSON data sent from the server, so we're going + // to ignore everything that's doesn't contain a "data" key. + + if (json?.StartsWith("data:") != true) + { + // No actual JSON data is present, ignore this message. + + continue; + } + + // Strip off the key and the colon so we can try to deserialize the JSON data. Keep in mind + // that the data key might be sent with no actual data present, so we're also checking up front + // to see whether we can just drop this as well or if it actually needs processing. + + json = json.Substring(5).Trim(); + + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + var features = ParseFeaturesFrom(json, logger, config); + + await onFeaturesRetrieved(features); + } + } + } + + private static IDictionary ParseFeaturesFrom(string json, ILogger logger, GrowthBookConfigurationOptions config) + { + var featuresResponse = JsonConvert.DeserializeObject(json); + + if (featuresResponse.EncryptedFeatures.IsNullOrWhitespace()) + { + logger.LogInformation($"API response JSON contained no encrypted features, returning '{featuresResponse.FeatureCount}' unencrypted features"); + return featuresResponse.Features; + } + + logger.LogInformation("API response JSON contained encrypted features, decrypting them now"); + logger.LogDebug($"Attempting to decrypt features with the provided decryption key '{config.DecryptionKey}'"); + + var decryptedFeaturesJson = featuresResponse.EncryptedFeatures.DecryptWith(config.DecryptionKey); + + logger.LogDebug($"Completed attempt to decrypt features which resulted in plaintext value of '{decryptedFeaturesJson}'"); + + var jsonObject = JObject.Parse(decryptedFeaturesJson); + + return jsonObject.ToObject>(); + } + } +} diff --git a/GrowthBook/Api/FeatureRefreshWorker.cs b/GrowthBook/Api/FeatureRefreshWorker.cs index b9e3154..72808c2 100644 --- a/GrowthBook/Api/FeatureRefreshWorker.cs +++ b/GrowthBook/Api/FeatureRefreshWorker.cs @@ -12,6 +12,7 @@ using System.Linq; using System.IO; using Microsoft.Extensions.Logging; +using GrowthBook.Api.Extensions; namespace GrowthBook.Api { @@ -63,20 +64,15 @@ public async Task> RefreshCacheFromApi(Cancellation _logger.LogInformation($"Making an HTTP request to the default Features API endpoint '{_featuresApiEndpoint}'"); var httpClient = _httpClientFactory.CreateClient(ConfiguredClients.DefaultApiClient); - var response = await httpClient.GetAsync(_featuresApiEndpoint, cancellationToken ?? _refreshWorkerCancellation.Token); - if (!response.IsSuccessStatusCode) + var response = await httpClient.GetFeaturesFrom(_featuresApiEndpoint, _logger, _config, cancellationToken ?? _refreshWorkerCancellation.Token); + + if (response.Features is null) { - _logger.LogError($"HTTP request to default Features API endpoint '{_featuresApiEndpoint}' resulted in a {response.StatusCode} status code"); return null; } - var json = await response.Content.ReadAsStringAsync(); - - _logger.LogDebug($"Read response JSON from default Features API request: '{json}'"); - - var features = GetFeaturesFrom(json); - await _cache.RefreshWith(features, cancellationToken); + await _cache.RefreshWith(response.Features, cancellationToken); // Now that the cache has been populated at least once, we need to see if we're allowed // to kick off the server sent events listener and make sure we're in the intended mode @@ -84,13 +80,11 @@ public async Task> RefreshCacheFromApi(Cancellation if (_config.PreferServerSentEvents) { - _isServerSentEventsEnabled = response.Headers.TryGetValues(HttpHeaders.ServerSentEvents.Key, out var values) && values.Contains(HttpHeaders.ServerSentEvents.EnabledValue); - - _logger.LogDebug($"{nameof(FeatureRefreshWorker)} is configured to prefer server sent events and enabled is now '{_isServerSentEventsEnabled}'"); + _isServerSentEventsEnabled = response.IsServerSentEventsEnabled; EnsureCorrectRefreshModeIsActive(); } - return features; + return response.Features; } private void EnsureCorrectRefreshModeIsActive() @@ -129,46 +123,13 @@ private Task ListenForServerSentEvents() _logger.LogInformation($"Making an HTTP request to server sent events endpoint '{_serverSentEventsApiEndpoint}'"); var httpClient = _httpClientFactory.CreateClient(ConfiguredClients.ServerSentEventsApiClient); - var stream = await httpClient.GetStreamAsync(_serverSentEventsApiEndpoint); - using (var reader = new StreamReader(stream)) + await httpClient.UpdateWithFeaturesStreamFrom(_serverSentEventsApiEndpoint, _logger, _config, _serverSentEventsListenerCancellation.Token, async features => { - while (!reader.EndOfStream && !_serverSentEventsListenerCancellation.IsCancellationRequested && !_refreshWorkerCancellation.IsCancellationRequested) - { - var json = reader.ReadLine(); - - // All server sent events will have the format ":" and each message - // is a single line in the stream. Right now, the only message that we care about - // has a key of "data" and value of the JSON data sent from the server, so we're going - // to ignore everything that's doesn't contain a "data" key. - - if (json?.StartsWith("data:") != true) - { - // No actual JSON data is present, ignore this message. - - continue; - } - - // Strip off the key and the colon so we can try to deserialize the JSON data. Keep in mind - // that the data key might be sent with no actual data present, so we're also checking up front - // to see whether we can just drop this as well or if it actually needs processing. - - json = json.Substring(5).Trim(); - - if (string.IsNullOrWhiteSpace(json)) - { - continue; - } - - _logger.LogDebug($"Read response JSON from server sent events API request: '{json}'"); - - var features = GetFeaturesFrom(json); - - await _cache.RefreshWith(features, _serverSentEventsListenerCancellation.Token); + await _cache.RefreshWith(features, _serverSentEventsListenerCancellation.Token); - _logger.LogInformation("Cache has been refreshed with server sent event features"); - } - } + _logger.LogInformation("Cache has been refreshed with server sent event features"); + }); } catch(HttpRequestException ex) {