diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c93b703..d928dfdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Optimizely C# SDK Changelog +## [Unreleased] + +### New Features +- Adds support for DatafileAuthToken in HttpProjectConfigManager and in it's Builder class. +- Added Gzip format for datafile download ## 3.4.1 April 29th, 2020 diff --git a/OptimizelySDK.Tests/ConfigTest/HttpProjectConfigManagerTest.cs b/OptimizelySDK.Tests/ConfigTest/HttpProjectConfigManagerTest.cs index ca5d2995..60e79db0 100644 --- a/OptimizelySDK.Tests/ConfigTest/HttpProjectConfigManagerTest.cs +++ b/OptimizelySDK.Tests/ConfigTest/HttpProjectConfigManagerTest.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ using OptimizelySDK.Tests.NotificationTests; using System; using System.Diagnostics; +using System.Net.Http; namespace OptimizelySDK.Tests.DatafileManagement_Tests { @@ -28,12 +29,19 @@ namespace OptimizelySDK.Tests.DatafileManagement_Tests public class HttpProjectConfigManagerTest { private Mock LoggerMock; + private Mock HttpClientMock; private Mock NotificationCallbackMock = new Mock(); [SetUp] public void Setup() { LoggerMock = new Mock(); + HttpClientMock = new Mock { CallBase = true }; + HttpClientMock.Reset(); + var field = typeof(HttpProjectConfigManager).GetField("Client", + System.Reflection.BindingFlags.Static | + System.Reflection.BindingFlags.NonPublic); + field.SetValue(field, HttpClientMock.Object); LoggerMock.Setup(l => l.Log(It.IsAny(), It.IsAny())); NotificationCallbackMock.Setup(nc => nc.TestConfigUpdateCallback()); @@ -57,7 +65,7 @@ public void TestHttpConfigManagerRetreiveProjectConfigByURL() [Test] public void TestHttpClientHandler() { - var httpConfigHandler = HttpProjectConfigManager.GetHttpClientHandler(); + var httpConfigHandler = HttpProjectConfigManager.HttpClient.GetHttpClientHandler(); Assert.IsTrue(httpConfigHandler.AutomaticDecompression == (System.Net.DecompressionMethods.Deflate | System.Net.DecompressionMethods.GZip)); } @@ -300,6 +308,102 @@ public void TestDefaultValuesWhenNotProvided() LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, $"No polling interval provided, using default period {TimeSpan.FromMinutes(5).TotalMilliseconds}ms")); LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, $"No Blocking timeout provided, using default blocking timeout {TimeSpan.FromSeconds(15).TotalMilliseconds}ms")); + } + + [Test] + public void TestAuthUrlWhenTokenProvided() + { + var t = MockSendAsync(); + + var httpManager = new HttpProjectConfigManager.Builder() + .WithSdkKey("QBw9gFM8oTn7ogY9ANCC1z") + .WithLogger(LoggerMock.Object) + .WithAccessToken("datafile1") + .WithBlockingTimeoutPeriod(TimeSpan.FromMilliseconds(50)) + .Build(true); + + // it's to wait if SendAsync is not triggered. + t.Wait(2000); + + HttpClientMock.Verify(_ => _.SendAsync( + It.Is(requestMessage => + requestMessage.RequestUri.ToString() == "https://config.optimizely.com/datafiles/auth/QBw9gFM8oTn7ogY9ANCC1z.json" + ))); + } + + [Test] + public void TestDefaultUrlWhenTokenNotProvided() + { + var t = MockSendAsync(); + + var httpManager = new HttpProjectConfigManager.Builder() + .WithSdkKey("QBw9gFM8oTn7ogY9ANCC1z") + .WithLogger(LoggerMock.Object) + .WithBlockingTimeoutPeriod(TimeSpan.FromMilliseconds(50)) + .Build(true); + + // it's to wait if SendAsync is not triggered. + t.Wait(2000); + HttpClientMock.Verify(_ => _.SendAsync( + It.Is(requestMessage => + requestMessage.RequestUri.ToString() == "https://cdn.optimizely.com/datafiles/QBw9gFM8oTn7ogY9ANCC1z.json" + ))); + } + + [Test] + public void TestAuthenticationHeaderWhenTokenProvided() + { + var t = MockSendAsync(); + + var httpManager = new HttpProjectConfigManager.Builder() + .WithSdkKey("QBw9gFM8oTn7ogY9ANCC1z") + .WithLogger(LoggerMock.Object) + .WithBlockingTimeoutPeriod(TimeSpan.FromMilliseconds(50)) + .WithAccessToken("datafile1") + .Build(true); + + // it's to wait if SendAsync is not triggered. + t.Wait(2000); + + HttpClientMock.Verify(_ => _.SendAsync( + It.Is(requestMessage => + requestMessage.Headers.Authorization.ToString() == "Bearer datafile1" + ))); + } + + [Test] + public void TestFormatUrlHigherPriorityThanDefaultUrl() + { + + var t = MockSendAsync(); + var httpManager = new HttpProjectConfigManager.Builder() + .WithSdkKey("QBw9gFM8oTn7ogY9ANCC1z") + .WithLogger(LoggerMock.Object) + .WithFormat("http://customformat/{0}.json") + .WithAccessToken("datafile1") + .WithBlockingTimeoutPeriod(TimeSpan.FromMilliseconds(50)) + .Build(true); + // it's to wait if SendAsync is not triggered. + t.Wait(2000); + HttpClientMock.Verify(_ => _.SendAsync( + It.Is(requestMessage => + requestMessage.RequestUri.ToString() == "http://customformat/QBw9gFM8oTn7ogY9ANCC1z.json" + ))); + + } + + public System.Threading.Tasks.Task MockSendAsync() + { + var t = new System.Threading.Tasks.TaskCompletionSource(); + + HttpClientMock.Setup(_ => _.SendAsync(It.IsAny())) + .Returns(System.Threading.Tasks.Task.FromResult(new HttpResponseMessage { StatusCode = System.Net.HttpStatusCode.OK, Content = new StringContent(string.Empty) })) + .Callback(() + => { + t.SetResult(true); + }); + + return t.Task; } #endregion diff --git a/OptimizelySDK/Config/HttpProjectConfigManager.cs b/OptimizelySDK/Config/HttpProjectConfigManager.cs index 54efa81a..51eb753d 100644 --- a/OptimizelySDK/Config/HttpProjectConfigManager.cs +++ b/OptimizelySDK/Config/HttpProjectConfigManager.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,34 +28,64 @@ public class HttpProjectConfigManager : PollingProjectConfigManager { private string Url; private string LastModifiedSince = string.Empty; - + private string DatafileAccessToken = string.Empty; private HttpProjectConfigManager(TimeSpan period, string url, TimeSpan blockingTimeout, bool autoUpdate, ILogger logger, IErrorHandler errorHandler) : base(period, blockingTimeout, autoUpdate, logger, errorHandler) { Url = url; } + private HttpProjectConfigManager(TimeSpan period, string url, TimeSpan blockingTimeout, bool autoUpdate, ILogger logger, IErrorHandler errorHandler, string datafileAccessToken) + : this(period, url, blockingTimeout, autoUpdate, logger, errorHandler) + { + DatafileAccessToken = datafileAccessToken; + } + public Task OnReady() { return CompletableConfigManager.Task; } #if !NET40 && !NET35 - private static System.Net.Http.HttpClient Client; - static HttpProjectConfigManager() + // HttpClient wrapper class which can be used to mock HttpClient for unit testing. + public class HttpClient { - Client = new System.Net.Http.HttpClient(GetHttpClientHandler()); - } + private System.Net.Http.HttpClient Client; - public static System.Net.Http.HttpClientHandler GetHttpClientHandler() - { - var handler = new System.Net.Http.HttpClientHandler() { - AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate - }; + public HttpClient() + { + Client = new System.Net.Http.HttpClient(GetHttpClientHandler()); + } - return handler; + public HttpClient(System.Net.Http.HttpClient httpClient) : this() + { + if (httpClient != null) { + Client = httpClient; + } + } + + public static System.Net.Http.HttpClientHandler GetHttpClientHandler() + { + var handler = new System.Net.Http.HttpClientHandler() { + AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate + }; + + return handler; + } + + public virtual Task SendAsync(System.Net.Http.HttpRequestMessage httpRequestMessage) + { + return Client.SendAsync(httpRequestMessage); + } } + private static HttpClient Client; + + static HttpProjectConfigManager() + { + Client = new HttpClient(); + } + private string GetRemoteDatafileResponse() { var request = new System.Net.Http.HttpRequestMessage { @@ -67,6 +97,10 @@ private string GetRemoteDatafileResponse() if (!string.IsNullOrEmpty(LastModifiedSince)) request.Headers.Add("If-Modified-Since", LastModifiedSince); + if (!string.IsNullOrEmpty(DatafileAccessToken)) { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", DatafileAccessToken); + } + var httpResponse = Client.SendAsync(request); httpResponse.Wait(); @@ -136,11 +170,12 @@ public class Builder private readonly TimeSpan DEFAULT_PERIOD = TimeSpan.FromMinutes(5); private readonly TimeSpan DEFAULT_BLOCKINGOUT_PERIOD = TimeSpan.FromSeconds(15); private readonly string DEFAULT_FORMAT = "https://cdn.optimizely.com/datafiles/{0}.json"; - + private readonly string DEFAULT_AUTHENTICATED_DATAFILE_FORMAT = "https://config.optimizely.com/datafiles/auth/{0}.json"; private string Datafile; + private string DatafileAccessToken; private string SdkKey; private string Url; - private string Format; + private string Format; private ILogger Logger; private IErrorHandler ErrorHandler; private TimeSpan Period; @@ -174,6 +209,14 @@ public Builder WithSdkKey(string sdkKey) return this; } +#if !NET40 && !NET35 + public Builder WithAccessToken(string accessToken) + { + this.DatafileAccessToken = accessToken; + + return this; + } +#endif public Builder WithUrl(string url) { @@ -260,15 +303,18 @@ public HttpProjectConfigManager Build(bool defer) ErrorHandler = new DefaultErrorHandler(); if (string.IsNullOrEmpty(Format)) { - Format = DEFAULT_FORMAT; - } - if (string.IsNullOrEmpty(Url) && string.IsNullOrEmpty(SdkKey)) - { - ErrorHandler.HandleError(new Exception("SdkKey cannot be null")); + if (string.IsNullOrEmpty(DatafileAccessToken)) { + Format = DEFAULT_FORMAT; + } else { + Format = DEFAULT_AUTHENTICATED_DATAFILE_FORMAT; + } } - else if (!string.IsNullOrEmpty(SdkKey)) - { + + if (string.IsNullOrEmpty(Url)) { + if (string.IsNullOrEmpty(SdkKey)) { + ErrorHandler.HandleError(new Exception("SdkKey cannot be null")); + } Url = string.Format(Format, SdkKey); } @@ -290,7 +336,7 @@ public HttpProjectConfigManager Build(bool defer) } - configManager = new HttpProjectConfigManager(Period, Url, BlockingTimeoutSpan, AutoUpdate, Logger, ErrorHandler); + configManager = new HttpProjectConfigManager(Period, Url, BlockingTimeoutSpan, AutoUpdate, Logger, ErrorHandler, DatafileAccessToken); if (Datafile != null) { diff --git a/OptimizelySDK/OptimizelyFactory.cs b/OptimizelySDK/OptimizelyFactory.cs index e077b412..b178c312 100644 --- a/OptimizelySDK/OptimizelyFactory.cs +++ b/OptimizelySDK/OptimizelyFactory.cs @@ -112,7 +112,39 @@ public static Optimizely NewDefaultInstance(string sdkKey) { return NewDefaultInstance(sdkKey, null); } +#if !NET40 && !NET35 + public static Optimizely NewDefaultInstance(string sdkKey, string fallback, string datafileAuthToken) + { + var logger = OptimizelyLogger ?? new DefaultLogger(); + var errorHandler = new DefaultErrorHandler(); + var eventDispatcher = new DefaultEventDispatcher(logger); + var builder = new HttpProjectConfigManager.Builder(); + var notificationCenter = new NotificationCenter(); + var configManager = builder + .WithSdkKey(sdkKey) + .WithDatafile(fallback) + .WithLogger(logger) + .WithErrorHandler(errorHandler) + .WithAccessToken(datafileAuthToken) + .WithNotificationCenter(notificationCenter) + .Build(true); + + EventProcessor eventProcessor = null; + +#if !NETSTANDARD1_6 && !NET35 + eventProcessor = new BatchEventProcessor.Builder() + .WithLogger(logger) + .WithMaxBatchSize(MaxEventBatchSize) + .WithFlushInterval(MaxEventFlushInterval) + .WithEventDispatcher(eventDispatcher) + .WithNotificationCenter(notificationCenter) + .Build(); +#endif + + return NewDefaultInstance(configManager, notificationCenter, eventDispatcher, errorHandler, logger, eventProcessor: eventProcessor); + } +#endif public static Optimizely NewDefaultInstance(string sdkKey, string fallback) { var logger = OptimizelyLogger ?? new DefaultLogger();