Skip to content

Commit

Permalink
Refactored to pull HTTP logic into extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
Norhaven committed Jan 27, 2024
1 parent 880b499 commit f374527
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 50 deletions.
110 changes: 110 additions & 0 deletions GrowthBook/Api/Extensions/HttpClientExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<string, Feature> Features { get; set; }
public string EncryptedFeatures { get; set; }
}

public static async Task<(IDictionary<string, Feature> 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<IDictionary<string, Feature>, 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 "<key>:<value>" 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<string, Feature> ParseFeaturesFrom(string json, ILogger logger, GrowthBookConfigurationOptions config)
{
var featuresResponse = JsonConvert.DeserializeObject<FeaturesResponse>(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<Dictionary<string, Feature>>();
}
}
}
61 changes: 11 additions & 50 deletions GrowthBook/Api/FeatureRefreshWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Linq;
using System.IO;
using Microsoft.Extensions.Logging;
using GrowthBook.Api.Extensions;

namespace GrowthBook.Api
{
Expand Down Expand Up @@ -63,34 +64,27 @@ public async Task<IDictionary<string, Feature>> 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
// of operating going forward.

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()
Expand Down Expand Up @@ -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 "<key>:<value>" 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)
{
Expand Down

0 comments on commit f374527

Please sign in to comment.