diff --git a/Keen.NET.Test/DelegatingHandlerMock.cs b/Keen.NET.Test/DelegatingHandlerMock.cs new file mode 100644 index 0000000..288e7f1 --- /dev/null +++ b/Keen.NET.Test/DelegatingHandlerMock.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + /// + /// Wraps an and allows for using it as a DelegatingHandler. + /// If the IHttpMessageHandler doesn't already have a default action set, we'll have it call + /// our own base SendAsync() which will forward the request down the handler chain. + /// + internal class DelegatingHandlerMock : DelegatingHandler + { + private readonly IHttpMessageHandler _handler; + + internal DelegatingHandlerMock(IHttpMessageHandler handler) + { + _handler = handler; + _handler.DefaultAsync = (_handler.DefaultAsync ?? base.SendAsync); + } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _handler.SendAsync(request, cancellationToken); + } + } +} diff --git a/Keen.NET.Test/EventCollectionMock.cs b/Keen.NET.Test/EventCollectionMock.cs index 8459535..1b4b035 100644 --- a/Keen.NET.Test/EventCollectionMock.cs +++ b/Keen.NET.Test/EventCollectionMock.cs @@ -1,7 +1,8 @@ -using System; -using System.Threading.Tasks; -using Keen.Core; +using Keen.Core; using Newtonsoft.Json.Linq; +using System; +using System.Threading.Tasks; + namespace Keen.Net.Test { diff --git a/Keen.NET.Test/FuncHandler.cs b/Keen.NET.Test/FuncHandler.cs new file mode 100644 index 0000000..8ce734d --- /dev/null +++ b/Keen.NET.Test/FuncHandler.cs @@ -0,0 +1,64 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + /// + /// An that has pre/post/default message handlers functors, + /// as well as a Func<> that produces the actual HttpResponseMessage. These can all be set by + /// test code and will be called if available. There are defaults in place that essentially do + /// nothing, but client code should make sure DefaultAsync gets set, either by a wrapper or + /// explicitly. + /// + internal class FuncHandler : IHttpMessageHandler + { + internal Action PreProcess = (request, ct) => { }; + + internal Func> ProduceResultAsync = + (request, ct) => Task.FromResult(null); + + internal Func PostProcess = (request, response) => response; + + internal bool DeferToDefault { get; set; } = true; + + public Func> DefaultAsync { get; set; } + + + public async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + PreProcess(request, cancellationToken); + HttpResponseMessage response = + await ProduceResultAsync(request, cancellationToken).ConfigureAwait(false); + + // Pass it along down the line if we didn't create a result already. + if (null == response) + { + if (DeferToDefault) + { + response = await DefaultAsync(request, cancellationToken).ConfigureAwait(false); + } + else + { + // Create a dummy successful response so HttpClient doesn't just throw always. + response = await HttpTests.CreateJsonStringResponseAsync(new { dummy = "" }) + .ConfigureAwait(false); + } + } + + PostProcess(request, response); + + return response; + } + } +} diff --git a/Keen.NET.Test/FunnelTest.cs b/Keen.NET.Test/FunnelTest.cs index 66c934c..6f4c96d 100644 --- a/Keen.NET.Test/FunnelTest.cs +++ b/Keen.NET.Test/FunnelTest.cs @@ -1,11 +1,12 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Keen.Core; +using Keen.Core; using Keen.Core.Query; using Moq; using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + namespace Keen.Net.Test { @@ -52,6 +53,7 @@ public override void Setup() public async Task Funnel_Simple_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); IEnumerable funnelsteps = new List { @@ -83,14 +85,13 @@ public async Task Funnel_Simple_Success() Result = new[] { 3, 2 } }; - Mock queryMock = null; if (UseMocks) { queryMock = new Mock(); queryMock.Setup(m => m.Funnel( It.Is>(f => f.Equals(funnelsteps)), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is(t => t == "") )) .Returns(Task.FromResult(expected)); @@ -98,7 +99,7 @@ public async Task Funnel_Simple_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryFunnelAsync(funnelsteps)); + var reply = (await client.QueryFunnelAsync(funnelsteps, timeframe)); Assert.NotNull(reply); Assert.NotNull(reply.Result); Assert.NotNull(reply.Steps); @@ -112,6 +113,7 @@ public async Task Funnel_Simple_Success() public async Task Funnel_Inverted_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); IEnumerable funnelsteps = new List { @@ -144,14 +146,13 @@ public async Task Funnel_Inverted_Success() Result = new [] { 3, 1 } }; - Mock queryMock = null; if (UseMocks) { queryMock = new Mock(); queryMock.Setup(m => m.Funnel( It.Is>(f => f.Equals(funnelsteps)), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is(t => t == "") )) .Returns(Task.FromResult(expected)); @@ -159,7 +160,7 @@ public async Task Funnel_Inverted_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryFunnelAsync(funnelsteps)); + var reply = (await client.QueryFunnelAsync(funnelsteps, timeframe)); Assert.NotNull(reply); Assert.NotNull(reply.Result); Assert.True(reply.Result.SequenceEqual(expected.Result)); @@ -174,6 +175,7 @@ public async Task Funnel_Inverted_Success() public async Task Funnel_Optional_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); IEnumerable funnelsteps = new [] { @@ -215,14 +217,13 @@ public async Task Funnel_Optional_Success() Result = new [] { 3, 2, 1 } }; - Mock queryMock = null; if (UseMocks) { queryMock = new Mock(); queryMock.Setup(m => m.Funnel( It.Is>(f => f.Equals(funnelsteps)), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is(t => t == "") )) .Returns(Task.FromResult(expected)); @@ -230,7 +231,7 @@ public async Task Funnel_Optional_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryFunnelAsync(funnelsteps)); + var reply = (await client.QueryFunnelAsync(funnelsteps, timeframe)); Assert.NotNull(reply); Assert.NotNull(reply.Result); Assert.True(reply.Result.SequenceEqual(expected.Result)); @@ -245,6 +246,7 @@ public async Task Funnel_Optional_Success() public async Task Funnel_ValidFilter_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); var filters = new List { new QueryFilter("id", QueryFilter.FilterOperator.GreaterThanOrEqual(), 0) }; IEnumerable funnelsteps = new [] @@ -279,14 +281,13 @@ public async Task Funnel_ValidFilter_Success() Result = new [] { 3, 2 } }; - Mock queryMock = null; if (UseMocks) { queryMock = new Mock(); queryMock.Setup(m => m.Funnel( It.Is>(f => f.Equals(funnelsteps)), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is(t => t == "") )) .Returns(Task.FromResult(expected)); @@ -294,7 +295,7 @@ public async Task Funnel_ValidFilter_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryFunnelAsync(funnelsteps)); + var reply = (await client.QueryFunnelAsync(funnelsteps, timeframe)); Assert.NotNull(reply); Assert.NotNull(reply.Result); Assert.True(reply.Result.SequenceEqual(expected.Result)); @@ -309,20 +310,21 @@ public async Task Funnel_ValidFilter_Success() public async Task Funnel_ValidTimeframe_Success() { var client = new KeenClient(SettingsEnv); - var timeframe = QueryRelativeTimeframe.PreviousHour(); + var timeframe = QueryRelativeTimeframe.ThisHour(); IEnumerable funnelsteps = new [] { new FunnelStep { - EventCollection = FunnelColA, + EventCollection = FunnelColA, ActorProperty = "id", - Timeframe = timeframe, + Timeframe = timeframe, // Issue #50 : These are ignored. }, new FunnelStep { - EventCollection = FunnelColB, - ActorProperty = "id" + EventCollection = FunnelColB, + ActorProperty = "id", + Timeframe = timeframe, }, }; @@ -332,24 +334,23 @@ public async Task Funnel_ValidTimeframe_Success() { new FunnelResultStep { - EventCollection = FunnelColA, + EventCollection = FunnelColA, }, new FunnelResultStep { - EventCollection = FunnelColB, + EventCollection = FunnelColB, }, }, Result = new [] { 3, 2 } }; - Mock queryMock = null; if (UseMocks) { queryMock = new Mock(); queryMock.Setup(m => m.Funnel( It.Is>(f => f.Equals(funnelsteps)), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is(t => t == "") )) .Returns(Task.FromResult(expected)); @@ -357,7 +358,7 @@ public async Task Funnel_ValidTimeframe_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryFunnelAsync(funnelsteps)); + var reply = (await client.QueryFunnelAsync(funnelsteps, timeframe)); Assert.NotNull(reply); Assert.NotNull(reply.Result); Assert.True(reply.Result.SequenceEqual(expected.Result)); @@ -372,6 +373,7 @@ public async Task Funnel_ValidTimeframe_Success() public async Task Funnel_WithActors_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); IEnumerable funnelsteps = new [] { @@ -413,7 +415,7 @@ public async Task Funnel_WithActors_Success() queryMock = new Mock(); queryMock.Setup(m => m.Funnel( It.Is>(f => f.Equals(funnelsteps)), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is(t => t == "") )) .Returns(Task.FromResult(expected)); @@ -421,7 +423,7 @@ public async Task Funnel_WithActors_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryFunnelAsync(funnelsteps)); + var reply = (await client.QueryFunnelAsync(funnelsteps, timeframe)); Assert.NotNull(reply); Assert.NotNull(reply.Actors); Assert.AreEqual(reply.Actors.Count(), 2); diff --git a/Keen.NET.Test/HttpClientHandlerMock.cs b/Keen.NET.Test/HttpClientHandlerMock.cs new file mode 100644 index 0000000..06b4f09 --- /dev/null +++ b/Keen.NET.Test/HttpClientHandlerMock.cs @@ -0,0 +1,31 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + /// + /// Wraps an and allows for using it as an HttpClientHandler. + /// If the IHttpMessageHandler doesn't already have a default action set, we'll have it call + /// our own base SendAsync() which will forward the request to the actual HttpClientHandler + /// implementation, with all the configuration and proxies and such, which may actually go out + /// over the network. + /// + internal class HttpClientHandlerMock : HttpClientHandler + { + internal readonly IHttpMessageHandler _handler; + + internal HttpClientHandlerMock(IHttpMessageHandler handler) + { + _handler = handler; + _handler.DefaultAsync = (_handler.DefaultAsync ?? base.SendAsync); + } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _handler.SendAsync(request, cancellationToken); + } + } +} diff --git a/Keen.NET.Test/HttpTests.cs b/Keen.NET.Test/HttpTests.cs new file mode 100644 index 0000000..25f5205 --- /dev/null +++ b/Keen.NET.Test/HttpTests.cs @@ -0,0 +1,155 @@ +using Keen.Core; +using Keen.Core.Query; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + [TestFixture] + internal class HttpTests : TestBase + { + [Test] + public void GetSdkVersion_Success() + { + string sdkVersion = KeenUtil.GetSdkVersion(); + + Assert.IsNotNull(sdkVersion); + Assert.IsNotEmpty(sdkVersion); + Assert.IsTrue(sdkVersion.StartsWith(".net")); + } + + [Test] + public async Task DefaultHeaders_Success() + { + object responseData = new[] { new { result = 2 } }; + + var handler = new FuncHandler() + { + PreProcess = (req, ct) => + { + // Make sure the default headers are in place + Assert.IsTrue(req.Headers.Contains("Keen-Sdk")); + Assert.AreEqual(KeenUtil.GetSdkVersion(), req.Headers.GetValues("Keen-Sdk").Single()); + + Assert.IsTrue(req.Headers.Contains("Authorization")); + + var key = req.Headers.GetValues("Authorization").Single(); + Assert.IsTrue(SettingsEnv.ReadKey == key || + SettingsEnv.WriteKey == key || + SettingsEnv.MasterKey == key); + }, + ProduceResultAsync = (req, ct) => + { + return CreateJsonStringResponseAsync(responseData); + }, + DeferToDefault = false + }; + + var client = new KeenClient(SettingsEnv, new TestKeenHttpClientProvider() + { + ProvideKeenHttpClient = + (url) => KeenHttpClientFactory.Create(url, + new HttpClientCache(), + null, + new DelegatingHandlerMock(handler)) + }); + + // Try out all the endpoints + Assert.DoesNotThrow(() => client.GetSchemas()); + + // Remaining operations expect an object, not an array of objects + responseData = new { result = 2 }; + + var @event = new { AProperty = "AValue" }; + Assert.DoesNotThrow(() => client.AddEvent("AddEventTest", @event)); + Assert.DoesNotThrow(() => client.AddEvents("AddEventTest", new[] { @event })); + + Assert.DoesNotThrow(() => client.DeleteCollection("DeleteColTest")); + Assert.IsNotNull(client.GetSchema("AddEventTest")); + + // Currently all the queries/extraction go through the same KeenWebApiRequest() call. + var count = await client.QueryAsync( + QueryType.Count(), + "testCollection", + "", + QueryRelativeTimeframe.ThisMonth()); + + Assert.IsNotNull(count); + Assert.AreEqual("2", count); + } + + internal static Task CreateJsonStringResponseAsync(object data) + { + return HttpTests.CreateJsonStringResponseAsync(data, HttpStatusCode.OK); + } + + internal static Task CreateJsonStringResponseAsync( + object data, + HttpStatusCode statusCode) + { + HttpResponseMessage mockResponse = new HttpResponseMessage(statusCode); + var dataStr = (data is Array ? JArray.FromObject(data).ToString(Formatting.None) : + JObject.FromObject(data).ToString(Formatting.None)); + var content = new StringContent(dataStr); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + mockResponse.Content = content; + + return Task.FromResult(mockResponse); + } + + // For communicating errors in mock responses. + internal static Task CreateJsonStringResponseAsync( + HttpStatusCode statusCode, + string message, + string errorCode) + { + var content = new StringContent(JObject.FromObject( + // Matches what the server returns and KeenUtil.CheckApiErrorCode() expects. + new + { + message = message, + error_code = errorCode + }).ToString(Formatting.None)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + HttpResponseMessage mockResponse = new HttpResponseMessage(statusCode) + { + Content = content + }; + + return Task.FromResult(mockResponse); + } + + internal static Uri GetUriForResource(IProjectSettings projectSettings, string resource) + { + string keenUrl = projectSettings.KeenUrl; + string projectId = projectSettings.ProjectId; + + return new Uri($"{keenUrl}projects/{projectId}/{resource}"); + } + + internal static Task ValidateRequest(HttpRequestMessage request, string expectedRequestBody) + { + return HttpTests.ValidateRequest(request, JObject.Parse(expectedRequestBody)); + } + + internal static async Task ValidateRequest(HttpRequestMessage request, JObject expectedRequestJson) + { + // Request should have a body + Assert.NotNull(request.Content); + + string requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + var requestJson = JObject.Parse(requestBody); + + Assert.IsTrue(JToken.DeepEquals(expectedRequestJson, requestJson)); + } + } +} diff --git a/Keen.NET.Test/IHttpMessageHandler.cs b/Keen.NET.Test/IHttpMessageHandler.cs new file mode 100644 index 0000000..60e16ca --- /dev/null +++ b/Keen.NET.Test/IHttpMessageHandler.cs @@ -0,0 +1,31 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + /// + /// Represents the main functionality needed to override both HttpClientHandler and + /// DelegatingHandler. This can be useful for implementing test code in pass-through fakes + /// where we want to alter some behavior, but let the rest execute normally. If the test just + /// tests/mutates and forwards to another handler, it can implement this interface and be used + /// in place of either type of handler in tests. + /// + /// It's a bad idea to reuse instances of this type, since the wrappers as well as the + /// HttpClient and pipeline code mess with their properties. Weirdness ensues, so create a + /// fresh instance every time at the point where you create the wrapper or stick it in the + /// pipeline, and don't store a reference to it unless it's needed to check later in an + /// assert or some verification logic, but generally don't reuse it. + /// + internal interface IHttpMessageHandler + { + Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken); + + Func> DefaultAsync { get; set; } + } +} diff --git a/Keen.NET.Test/Keen.NET.Test.csproj b/Keen.NET.Test/Keen.NET.Test.csproj index aa0eecc..0710a75 100644 --- a/Keen.NET.Test/Keen.NET.Test.csproj +++ b/Keen.NET.Test/Keen.NET.Test.csproj @@ -93,12 +93,20 @@ + + + + + + + + diff --git a/Keen.NET.Test/KeenClientTest.cs b/Keen.NET.Test/KeenClientTest.cs index 0015f76..d00b2fa 100644 --- a/Keen.NET.Test/KeenClientTest.cs +++ b/Keen.NET.Test/KeenClientTest.cs @@ -1,15 +1,14 @@ -using System; +using Keen.Core; +using Keen.Core.EventCache; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using System; using System.Collections.Generic; +using System.Dynamic; using System.Linq; using System.Threading.Tasks; -using Keen.Core.Query; -using Moq; -using NUnit.Framework; -using Keen.Core; -using System.Dynamic; -using Newtonsoft.Json.Linq; -using Keen.Core.EventCache; namespace Keen.Net.Test { @@ -213,7 +212,8 @@ public void GetCollectionSchemas_Success() [Test] public void GetCollectionSchema_InvalidProjectId_Throws() { - var settings = new ProjectSettingsProvider(projectId: "X", masterKey: SettingsEnv.MasterKey); + var settings = new ProjectSettingsProvider(projectId: "X", + readKey: SettingsEnv.ReadKey); var client = new KeenClient(settings); if (UseMocks) client.EventCollection = new EventCollectionMock(settings, @@ -489,17 +489,6 @@ public void DeleteCollection_Success() // Idempotent, does not matter if collection does not exist. Assert.DoesNotThrow(() => client.DeleteCollection("DeleteColTest")); } - - [Test] - public void GetSdkVersion_Success() - { - // TODO : Decide on a better place for this when we break out tests and do a CC push. - string sdkVersion = KeenUtil.GetSdkVersion(); - - Assert.IsNotNull(sdkVersion); - Assert.IsNotEmpty(sdkVersion); - Assert.IsTrue(sdkVersion.StartsWith(".net")); - } } [TestFixture] @@ -849,6 +838,7 @@ public class AsyncTests : TestBase public async Task Async_DeleteCollection_Success() { var client = new KeenClient(SettingsEnv); + if (UseMocks) client.EventCollection = new EventCollectionMock(SettingsEnv, deleteCollection: new Action((c, p) => @@ -898,7 +888,7 @@ public void Async_AddEvent_ValidProjectIdInvalidWriteKey_Throws() throw new KeenInvalidApiKeyException(c); })); - Assert.ThrowsAsync(() => client.AddEventAsync("AddEventTest", new { AProperty = "Value" })); + Assert.ThrowsAsync(() => client.AddEventAsync("AddEventTest", new { AProperty = "Value" })); } [Test] diff --git a/Keen.NET.Test/QueryTest.cs b/Keen.NET.Test/QueryTest.cs index d341b5c..810ca11 100644 --- a/Keen.NET.Test/QueryTest.cs +++ b/Keen.NET.Test/QueryTest.cs @@ -1,16 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; +using Keen.Core; +using Keen.Core.Query; using Moq; using Newtonsoft.Json.Linq; using NUnit.Framework; -using Keen.Core; -using Keen.Core.Query; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Dynamic; +using System.Linq; +using System.Threading.Tasks; + namespace Keen.Net.Test { @@ -48,7 +47,7 @@ public void ReadKeyOnly_Success() { // Server is required for this test // Also, test depends on existance of collection "AddEventTest" - Assert.DoesNotThrow(() => client.Query(QueryType.Count(), "AddEventTest", "")); + Assert.DoesNotThrow(() => client.Query(QueryType.Count(), "AddEventTest", "", QueryRelativeTimeframe.ThisHour())); } } @@ -72,7 +71,7 @@ public async Task AvailableQueries_Success() queryMock = new Mock(); queryMock.Setup(m=>m.AvailableQueries()) .Returns(Task.FromResult(testResult)); - + client.Queries = queryMock.Object; } @@ -231,7 +230,6 @@ public async Task Query_ValidRelativeGroupInterval_Success() } } - [Test] public async Task Query_ValidAbsoluteInterval_Success() { @@ -334,6 +332,7 @@ public async Task Query_ValidFilter_Success() { var client = new KeenClient(SettingsEnv); var filters = new List(){ new QueryFilter("field1", QueryFilter.FilterOperator.GreaterThan(), "1") }; + var timeframe = QueryRelativeTimeframe.ThisHour(); Mock queryMock = null; if (UseMocks) @@ -343,7 +342,7 @@ public async Task Query_ValidFilter_Success() It.Is(q => q == QueryType.Count()), It.Is(c => c == testCol), It.Is(p => p == ""), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is>(f => f == filters), It.Is(z => z == ""))) .Returns(Task.FromResult("1")); @@ -351,7 +350,7 @@ public async Task Query_ValidFilter_Success() client.Queries = queryMock.Object; } - var count = await client.QueryAsync(QueryType.Count(), testCol, "", null, filters); + var count = await client.QueryAsync(QueryType.Count(), testCol, "", timeframe, filters); Assert.IsNotNull(count); if (null != queryMock) @@ -360,7 +359,6 @@ public async Task Query_ValidFilter_Success() } } - [Test] public async Task CountUnique_ValidAbsolute_Success() { @@ -423,7 +421,6 @@ public async Task Minimum_ValidAbsolute_Success() queryMock.VerifyAll(); } - [Test] public async Task Maximum_ValidAbsolute_Success() { @@ -485,8 +482,6 @@ public async Task Average_ValidAbsolute_Success() queryMock.VerifyAll(); } - - [Test] public async Task Sum_ValidAbsolute_Success() { @@ -621,6 +616,7 @@ public async Task SelectUnique_ValidFilter_Success() { var client = new KeenClient(SettingsEnv); var prop = "field1"; + var timeframe = QueryRelativeTimeframe.ThisHour(); var filters = new List() { new QueryFilter("field1", QueryFilter.FilterOperator.GreaterThan(), "1") }; var result = "hello,goodbye,I'm late"; @@ -632,7 +628,7 @@ public async Task SelectUnique_ValidFilter_Success() It.Is(q => q == QueryType.SelectUnique()), It.Is(c => c == testCol), It.Is(p => p == prop), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is>(f => f == filters), It.Is(t => t == "") )) @@ -641,7 +637,7 @@ public async Task SelectUnique_ValidFilter_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryAsync(QueryType.SelectUnique(), testCol, prop, null, filters)).ToList(); + var reply = (await client.QueryAsync(QueryType.SelectUnique(), testCol, prop, timeframe, filters)).ToList(); if (null != queryMock) queryMock.VerifyAll(); @@ -736,7 +732,6 @@ public async Task SelectUnique_ValidAbsoluteIntervalGroup_Success() queryMock.VerifyAll(); } - [Test] public async Task SelectUnique_ValidRelativeInterval_Success() { @@ -772,9 +767,6 @@ public async Task SelectUnique_ValidRelativeInterval_Success() queryMock.VerifyAll(); } - - - [Test] public async Task ExtractResource_ValidAbsolute_Success() { @@ -841,6 +833,7 @@ public async Task ExtractResource_ValidRelative_Success() public async Task ExtractResource_ValidFilter_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); var filters = new List() { new QueryFilter("field1", QueryFilter.FilterOperator.GreaterThan(), "1") }; dynamic eo = new ExpandoObject(); eo.field1 = "8888"; @@ -852,7 +845,7 @@ public async Task ExtractResource_ValidFilter_Success() queryMock = new Mock(); queryMock.Setup(m => m.Extract( It.Is(c => c == testCol), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is>(f => f == filters), It.Is(l => l == 0), It.Is(t => t == "") @@ -862,17 +855,18 @@ public async Task ExtractResource_ValidFilter_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryExtractResourceAsync(testCol, null, filters)).ToList(); + var reply = (await client.QueryExtractResourceAsync(testCol, timeframe, filters)).ToList(); if (null != queryMock) queryMock.VerifyAll(); } - [Test] public async Task MultiAnalysis_Valid_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); + IEnumerable param = new List() { new MultiAnalysisParam("first", MultiAnalysisParam.Metric.Count()), @@ -891,7 +885,7 @@ public async Task MultiAnalysis_Valid_Success() queryMock.Setup(m => m.MultiAnalysis( It.Is(c => c == testCol), It.Is>(p => p == param), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is>(f => f == null), It.Is(tz => tz == "") )) @@ -900,7 +894,7 @@ public async Task MultiAnalysis_Valid_Success() client.Queries = queryMock.Object; } - var reply = await client.QueryMultiAnalysisAsync(testCol, param, null, null, ""); + var reply = await client.QueryMultiAnalysisAsync(testCol, param, timeframe, null, ""); if (null != queryMock) { @@ -954,6 +948,7 @@ public async Task MultiAnalysis_ValidRelativeTimeFrame_Success() public async Task MultiAnalysis_ValidGroupBy_Success() { var client = new KeenClient(SettingsEnv); + var timeframe = QueryRelativeTimeframe.ThisHour(); var groupby = "field1"; IEnumerable param = new List() { @@ -979,7 +974,7 @@ public async Task MultiAnalysis_ValidGroupBy_Success() queryMock.Setup(m => m.MultiAnalysis( It.Is(c => c == testCol), It.Is>(p => p == param), - It.Is(t => t == null), + It.Is(t => t == timeframe), It.Is>(f => f == null), It.Is(g => g == groupby), It.Is(tz => tz == "") @@ -989,7 +984,7 @@ public async Task MultiAnalysis_ValidGroupBy_Success() client.Queries = queryMock.Object; } - var reply = (await client.QueryMultiAnalysisGroupAsync(testCol, param, null, null, groupby, "")).ToList(); + var reply = (await client.QueryMultiAnalysisGroupAsync(testCol, param, timeframe, null, groupby, "")).ToList(); if (null != queryMock) { @@ -1109,7 +1104,6 @@ public async Task MultiAnalysis_ValidInterval_Success() Assert.AreEqual(reply.Count(), result.Count()); } } - } [TestFixture] @@ -1122,9 +1116,15 @@ public void Constructor_InvalidProperty_Throws() } [Test] - public void Constructor_InvalidValue_Throws() + public void Constructor_InvalidOp_Throws() { - Assert.Throws(() => new QueryFilter("prop", QueryFilter.FilterOperator.Equals(), null)); + Assert.Throws(() => new QueryFilter("prop", null, "val")); + } + + [Test] + public void Constructor_NullPropertyValue_Success() + { + Assert.DoesNotThrow(() => new QueryFilter("prop", QueryFilter.FilterOperator.Equals(), null)); } [Test] @@ -1148,6 +1148,20 @@ public void Serialize_SimpleValue_Success() Assert.AreEqual(expectedJson, json); } + [Test] + public void Serialize_NullValue_Success() + { + var filter = new QueryFilter("prop", QueryFilter.FilterOperator.Equals(), null); + + var json = JObject.FromObject(filter).ToString(Newtonsoft.Json.Formatting.None); + + const string expectedJson = "{\"property_name\":\"prop\"," + + "\"operator\":\"eq\"," + + "\"property_value\":null}"; + + Assert.AreEqual(expectedJson, json); + } + [Test] public void Serialize_GeoValue_Success() { @@ -1169,6 +1183,5 @@ public void Serialize_GeoValue_Success() Assert.AreEqual(expectedJson, json); } - } } diff --git a/Keen.NET.Test/QueryTests_Integration.cs b/Keen.NET.Test/QueryTests_Integration.cs new file mode 100644 index 0000000..6994f2e --- /dev/null +++ b/Keen.NET.Test/QueryTests_Integration.cs @@ -0,0 +1,123 @@ +using Keen.Core; +using Keen.Core.Query; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + /// + /// Integration tests for Queries. These will exercise more than unit tests, like the + /// integration between KeenClient, Queries and KeenHttpClient. + /// + class QueryTests_Integration : TestBase + { + [Test] + public async Task QueryFilter_NotContains_Success() + { + var queriesUrl = HttpTests.GetUriForResource(SettingsEnv, + KeenConstants.QueriesResource); + + var handler = new FuncHandler() + { + PreProcess = (req, ct) => + { + var queryStr = req.RequestUri.Query; + + // Make sure our filter properties are in the query string + Assert.IsTrue(queryStr.Contains("propertyName") && + queryStr.Contains("four") && + queryStr.Contains(QueryFilter.FilterOperator.NotContains())); + }, + ProduceResultAsync = (req, ct) => + { + return HttpTests.CreateJsonStringResponseAsync(new { result = 2 }); + }, + DeferToDefault = false + }; + + // NOTE : This example shows use of UrlToMessageHandler, but since we only make one + // request to a single endpoint, we could just directly use the FuncHandler here. + var urlHandler = new UrlToMessageHandler( + new Dictionary + { + { queriesUrl, handler } + }) + { DeferToDefault = false }; + + var client = new KeenClient(SettingsEnv, new TestKeenHttpClientProvider() + { + ProvideKeenHttpClient = + (url) => KeenHttpClientFactory.Create(url, + new HttpClientCache(), + null, + new DelegatingHandlerMock(urlHandler)) + }); + + var filters = new List + { + new QueryFilter("propertyName", QueryFilter.FilterOperator.NotContains(), "four") + }; + + var count = await client.QueryAsync( + QueryType.Count(), + "testCollection", + "", + QueryRelativeTimeframe.ThisMonth(), + filters); + + Assert.IsNotNull(count); + Assert.AreEqual("2", count); + } + + [Test] + public async Task QueryFilter_NullPropertyValue_Success() + { + // TODO : Consolidate this FuncHandler/KeenClient setup into a helper method. + + var handler = new FuncHandler() + { + PreProcess = (req, ct) => + { + var queryStr = req.RequestUri.Query; + + // Make sure our filter properties are in the query string + Assert.IsTrue(queryStr.Contains("propertyName") && + queryStr.Contains("null") && + queryStr.Contains(QueryFilter.FilterOperator.Equals())); + }, + ProduceResultAsync = (req, ct) => + { + return HttpTests.CreateJsonStringResponseAsync(new { result = 2 }); + }, + DeferToDefault = false + }; + + var client = new KeenClient(SettingsEnv, new TestKeenHttpClientProvider() + { + ProvideKeenHttpClient = + (url) => KeenHttpClientFactory.Create(url, + new HttpClientCache(), + null, + new DelegatingHandlerMock(handler)) + }); + + var filters = new List + { + new QueryFilter("propertyName", QueryFilter.FilterOperator.Equals(), null) + }; + + var count = await client.QueryAsync( + QueryType.Count(), + "testCollection", + "", + QueryRelativeTimeframe.ThisMonth(), + filters); + + Assert.IsNotNull(count); + Assert.AreEqual("2", count); + } + } +} diff --git a/Keen.NET.Test/TestKeenHttpClientProvider.cs b/Keen.NET.Test/TestKeenHttpClientProvider.cs new file mode 100644 index 0000000..c518af2 --- /dev/null +++ b/Keen.NET.Test/TestKeenHttpClientProvider.cs @@ -0,0 +1,23 @@ +using Keen.Core; +using System; + + +namespace Keen.Net.Test +{ + /// + /// An implementation of that behaves just like + /// by default, but allows for easily overriding this + /// behavior with a Func<> for use in tests. + /// + internal class TestKeenHttpClientProvider : IKeenHttpClientProvider + { + internal Func ProvideKeenHttpClient = + (url) => KeenHttpClientFactory.Create(url, HttpClientCache.Instance); + + + public IKeenHttpClient GetForUrl(Uri baseUrl) + { + return ProvideKeenHttpClient(baseUrl); + } + } +} diff --git a/Keen.NET.Test/UrlToMessageHandler.cs b/Keen.NET.Test/UrlToMessageHandler.cs new file mode 100644 index 0000000..5683ba9 --- /dev/null +++ b/Keen.NET.Test/UrlToMessageHandler.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + + +namespace Keen.Net.Test +{ + /// + /// An that matches request URIs with other + /// IHttpMessageHandler instances. If configured as such, matching can be done such that when + /// a precise match for a URL isn't found, any base URL entry will be used. This would mean one + /// could match, say, any queries under a certain project ID without specifying all the + /// query parameters, if that's useful. Another entry could match events, for example. By + /// default, if no strict or loose match is found, this will try to forward to DefaultAsync, so + /// either make sure that is set by a wrapper or explicitly, or configure to not defer to + /// the default action. + /// + /// This is handy to set up some validation and return canned responses for sets of URLs. + /// + /// By sticking IHttpMessageHandler instances in this mapping, their DefaultAsync properties + /// get forwarded to the DefaultAsync of this handler. That means if a match is found, and the + /// handler used decides to call DefaultAsync, it will call this handler's default action, + /// bypassing DeferToDefault, so make sure the contained handlers know whether or not to call + /// default. TODO : Evaluate this behavior since maybe the defaults here are confusing. + /// + /// + internal class UrlToMessageHandler : IHttpMessageHandler + { + private readonly IDictionary _urlsToHandlers; + + public Func> DefaultAsync { get; set; } + + internal bool DeferToDefault { get; set; } = true; + + internal bool MatchBaseUrls { get; set; } = true; + + internal UrlToMessageHandler(IDictionary urlsToHandlers) + { + _urlsToHandlers = new Dictionary(urlsToHandlers); + + foreach (var handler in _urlsToHandlers.Values) + { + // Lazily dispatch to whatever our default handler gets set to. + // Be careful because trying to reuse handler instances will lead to strange + // outcomes w.r.t. the default behavior of these IHttpMessageHandlers. + handler.DefaultAsync = (request, ct) => DefaultAsync(request, ct); + } + } + + public async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response = null; + IHttpMessageHandler handler = null; + + // First check for a perfect match, or if there's a key that's a base url of the request + // url, match it if client code chooses to accept that. + if (_urlsToHandlers.TryGetValue(request.RequestUri, out handler) || + (MatchBaseUrls && null != (handler = _urlsToHandlers.FirstOrDefault( + entry => entry.Key.IsBaseOf(request.RequestUri)).Value))) + { + response = + await handler.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + else if (DeferToDefault) + { + response = await DefaultAsync(request, cancellationToken).ConfigureAwait(false); + } + else + { + Console.WriteLine(string.Format("WARNING: No validator found for absolute URI: {0}", + request.RequestUri.AbsoluteUri)); + + // No handler found, so return 404 + response = await HttpTests.CreateJsonStringResponseAsync( + HttpStatusCode.NotFound, "Resource not found.", "ResourceNotFoundError") + .ConfigureAwait(false); + } + + return response; + } + } +} diff --git a/Keen.Net/ProjectSettingsProviderEnv.cs b/Keen.Net/ProjectSettingsProviderEnv.cs index 4bd6200..ec6cc47 100644 --- a/Keen.Net/ProjectSettingsProviderEnv.cs +++ b/Keen.Net/ProjectSettingsProviderEnv.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Keen.Core; +using Keen.Core; +using System; + namespace Keen.Net { @@ -14,9 +12,10 @@ public class ProjectSettingsProviderEnv : ProjectSettingsProvider /// /// Reads the project settings from environment variables /// Project ID should be in variable KEEN_PROJECT_ID - /// Master Key should be in variable KEEN_MASTER_ID - /// Write Key should be in variable KEEN_WRITE_ID - /// ReadKey should be in variable KEEN_READ_ID + /// Master Key should be in variable KEEN_MASTER_KEY + /// Write Key should be in variable KEEN_WRITE_KEY + /// ReadKey should be in variable KEEN_READ_KEY + /// Keen.IO API url should be in variable KEEN_SERVER_URL /// public ProjectSettingsProviderEnv() { diff --git a/Keen.Net/ProjectSettingsProviderFile.cs b/Keen.Net/ProjectSettingsProviderFile.cs index 72c1c7b..97cc24f 100644 --- a/Keen.Net/ProjectSettingsProviderFile.cs +++ b/Keen.Net/ProjectSettingsProviderFile.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Keen.Core; +using Keen.Core; using System.IO; + namespace Keen.Net { /// @@ -15,11 +12,14 @@ public class ProjectSettingsProviderFile : ProjectSettingsProvider /// /// Reads the project settings from a text file. /// Each setting takes one line, in the order Project ID, - /// Master Key, Write Key ReadKey. Unused values should be represented + /// Master Key, Write Key, Read Key. Unused values should be represented /// with a blank line. /// public ProjectSettingsProviderFile(string filePath) { + // TODO : Add Keen Server URL as one of the lines, optionally. + // TODO : Master key maybe should be de-emphasized and not be first. + // TODO : Share init of properties with base class implementation. var values = File.ReadAllLines(filePath); if (values.Length != 4) throw new KeenException("Invalid project settings file, file must contain exactly 4 lines: " + filePath); diff --git a/Keen.Net/ScopedKey.cs b/Keen.Net/ScopedKey.cs index 7628166..bb7b5e3 100644 --- a/Keen.Net/ScopedKey.cs +++ b/Keen.Net/ScopedKey.cs @@ -31,7 +31,7 @@ public static string Encrypt(string apiKey, object secOptions, string IV = null) { var secOptionsJson = JObject.FromObject(secOptions ?? new object()).ToString(Formatting.None); - return EncryptString( apiKey, secOptionsJson, IV); + return EncryptString(apiKey, secOptionsJson, IV); } /// diff --git a/Keen/CachedEvent.cs b/Keen/CachedEvent.cs index 9b053b7..9cc2714 100644 --- a/Keen/CachedEvent.cs +++ b/Keen/CachedEvent.cs @@ -1,8 +1,6 @@ using Newtonsoft.Json.Linq; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; + namespace Keen.Core.EventCache { diff --git a/Keen/DataEnrichment/EventAddOn.cs b/Keen/DataEnrichment/EventAddOn.cs index 60b3610..f6f1e1c 100644 --- a/Keen/DataEnrichment/EventAddOn.cs +++ b/Keen/DataEnrichment/EventAddOn.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using Newtonsoft.Json; +using System.Collections.Generic; + namespace Keen.Core.DataEnrichment { @@ -46,7 +47,7 @@ public AddOn(string name, IDictionary input, string output) /// /// Build and return an IpToGeo Data Enhancement add-on. This add-on reads /// an IP address from the field identified by the input parameter and writes - /// data about the geographical location to the field identfied by the output parameter. + /// data about the geographical location to the field identified by the output parameter. /// /// Name of field to store the geographical information /// Name of field containing an IP address @@ -62,7 +63,7 @@ public static AddOn IpToGeo(string ipField, string outputField) /// Build and return a User-Agent Data Enhancement add-on. This add-on reads /// a user agent string from the field identified by the input parameter and parses it /// into the device, browser, browser version, OS, and OS version fields and stores that - /// data in the field identfied by the output parameter. + /// data in the field identified by the output parameter. /// /// Name of field to store the parsed user agent field /// Name of field containing the user agent string diff --git a/Keen/DynamicPropertyValue.cs b/Keen/DynamicPropertyValue.cs index 22b8378..5afe658 100644 --- a/Keen/DynamicPropertyValue.cs +++ b/Keen/DynamicPropertyValue.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; + namespace Keen.Core { diff --git a/Keen/Event.cs b/Keen/Event.cs index dd7e3a0..ec6a690 100644 --- a/Keen/Event.cs +++ b/Keen/Event.cs @@ -2,13 +2,10 @@ using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; -using System.Net.Http; -using System.Text; using System.Threading.Tasks; + namespace Keen.Core { /// @@ -16,8 +13,43 @@ namespace Keen.Core /// internal class Event : IEvent { - private IProjectSettings _prjSettings; - private string _serverUrl; + private readonly IKeenHttpClient _keenHttpClient; + private readonly string _eventsRelativeUrl; + private readonly string _readKey; + private readonly string _writeKey; + + + internal Event(IProjectSettings prjSettings, + IKeenHttpClientProvider keenHttpClientProvider) + { + if (null == prjSettings) + { + throw new ArgumentNullException(nameof(prjSettings), + "Project Settings must be provided."); + } + + if (null == keenHttpClientProvider) + { + throw new ArgumentNullException(nameof(keenHttpClientProvider), + "A KeenHttpClient provider must be provided."); + } + + if (string.IsNullOrWhiteSpace(prjSettings.KeenUrl) || + !Uri.IsWellFormedUriString(prjSettings.KeenUrl, UriKind.Absolute)) + { + throw new KeenException( + "A properly formatted KeenUrl must be provided via Project Settings."); + } + + var serverBaseUrl = new Uri(prjSettings.KeenUrl); + _keenHttpClient = keenHttpClientProvider.GetForUrl(serverBaseUrl); + _eventsRelativeUrl = KeenHttpClient.GetRelativeUrl(prjSettings.ProjectId, + KeenConstants.EventsResource); + + _readKey = prjSettings.ReadKey; + _writeKey = prjSettings.WriteKey; + } + /// /// Get details of all schemas in the project. @@ -25,25 +57,33 @@ internal class Event : IEvent /// public async Task GetSchemas() { - using (var client = new HttpClient()) + if (string.IsNullOrWhiteSpace(_readKey)) + { + throw new KeenException("An API ReadKey is required to get schemas."); + } + + var responseMsg = await _keenHttpClient + .GetAsync(_eventsRelativeUrl, _readKey) + .ConfigureAwait(continueOnCapturedContext: false); + + var responseString = await responseMsg + .Content + .ReadAsStringAsync() + .ConfigureAwait(continueOnCapturedContext: false); + + var response = JArray.Parse(responseString); + + // error checking, throw an exception with information from the json + // response if available, then check the HTTP response. + KeenUtil.CheckApiErrorCode(response); + + if (!responseMsg.IsSuccessStatusCode) { - client.DefaultRequestHeaders.Add("Authorization", _prjSettings.MasterKey); - client.DefaultRequestHeaders.Add("Keen-Sdk", KeenUtil.GetSdkVersion()); - - var responseMsg = await client.GetAsync(_serverUrl) - .ConfigureAwait(continueOnCapturedContext: false); - var responseString = await responseMsg.Content.ReadAsStringAsync() - .ConfigureAwait(continueOnCapturedContext: false); - dynamic response = JArray.Parse(responseString); - - // error checking, throw an exception with information from the json - // response if available, then check the HTTP response. - KeenUtil.CheckApiErrorCode(response); - if (!responseMsg.IsSuccessStatusCode) - throw new KeenException("GetSchemas failed with status: " + responseMsg.StatusCode); - - return response; + throw new KeenException("GetSchemas failed with status: " + + responseMsg.StatusCode); } + + return response; } /// @@ -53,60 +93,64 @@ public async Task GetSchemas() /// public async Task> AddEvents(JObject events) { + if (string.IsNullOrWhiteSpace(_writeKey)) + { + throw new KeenException("An API WriteKey is required to add events."); + } + var content = events.ToString(); - using (var client = new HttpClient()) - using (var contentStream = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(content)))) + var responseMsg = await _keenHttpClient + .PostAsync(_eventsRelativeUrl, _writeKey, content) + .ConfigureAwait(continueOnCapturedContext: false); + + var responseString = await responseMsg + .Content + .ReadAsStringAsync() + .ConfigureAwait(continueOnCapturedContext: false); + + JObject jsonResponse = null; + + try + { + // Normally the response content should be parsable JSON, + // but if the server returned a 404 error page or something + // like that, this will throw. + jsonResponse = JObject.Parse(responseString); + + // TODO : Why do we not call KeenUtil.CheckApiErrorCode(jsonResponse); ?? + } + catch (Exception) { - contentStream.Headers.Add("content-type", "application/json"); - - client.DefaultRequestHeaders.Add("Authorization", _prjSettings.WriteKey); - client.DefaultRequestHeaders.Add("Keen-Sdk", KeenUtil.GetSdkVersion()); - - var httpResponse = await client.PostAsync(_serverUrl, contentStream) - .ConfigureAwait(continueOnCapturedContext: false); - var responseString = await httpResponse.Content.ReadAsStringAsync() - .ConfigureAwait(continueOnCapturedContext: false); - JObject jsonResponse = null; - try - { - // Normally the response content should be parsable JSON, - // but if the server returned a 404 error page or something - // like that, this will throw. - jsonResponse = JObject.Parse(responseString); - } - catch (Exception) - { } - - if (!httpResponse.IsSuccessStatusCode) - throw new KeenException("AddEvents failed with status: " + httpResponse); - if (null == jsonResponse) - throw new KeenException("AddEvents failed with empty response from server."); - - // error checking, return failed events in the list, - // or if the HTTP response is a failure, throw. - var failedItems = from respCols in jsonResponse.Properties() - from eventsCols in events.Properties() - where respCols.Name == eventsCols.Name - let collection = respCols.Name - let combined = eventsCols.Children().Children() - .Zip(respCols.Children().Children(), - (e, r) => new { eventObj = (JObject)e, result = (JObject)r }) - from e in combined - where !(bool)(e.result.Property("success").Value) - select new CachedEvent(collection, e.eventObj, KeenUtil.GetBulkApiError(e.result)); - - return failedItems; } - } - public Event(IProjectSettings prjSettings) - { - _prjSettings = prjSettings; + if (!responseMsg.IsSuccessStatusCode) + { + throw new KeenException("AddEvents failed with status: " + responseMsg.StatusCode); + } - _serverUrl = string.Format("{0}projects/{1}/{2}", - _prjSettings.KeenUrl, _prjSettings.ProjectId, KeenConstants.EventsResource); - } + if (null == jsonResponse) + { + throw new KeenException("AddEvents failed with empty response from server."); + } + // error checking, return failed events in the list, + // or if the HTTP response is a failure, throw. + var failedItems = + from respCols in jsonResponse.Properties() + from eventsCols in events.Properties() + where respCols.Name == eventsCols.Name + let collection = respCols.Name + let combined = eventsCols.Children().Children() + .Zip(respCols.Children().Children(), + (e, r) => new { eventObj = (JObject)e, result = (JObject)r }) + from e in combined + where !(bool)(e.result.Property("success").Value) + select new CachedEvent(collection, + e.eventObj, + KeenUtil.GetBulkApiError(e.result)); + + return failedItems; + } } } diff --git a/Keen/EventCachePortable.cs b/Keen/EventCachePortable.cs index a8e14df..b3b18ac 100644 --- a/Keen/EventCachePortable.cs +++ b/Keen/EventCachePortable.cs @@ -5,9 +5,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Text; using System.Threading.Tasks; + namespace Keen.Core { /// @@ -21,8 +21,7 @@ public class EventCachePortable : IEventCache { private static Queue events = new Queue(); - private EventCachePortable() - {} + private EventCachePortable() { } /// /// Create, initialize and return an instance of EventCachePortable. @@ -49,7 +48,7 @@ public static async Task NewAsync() { var instance = new EventCachePortable(); - var keenFolder = await getKeenFolder() + var keenFolder = await GetKeenFolder() .ConfigureAwait(continueOnCapturedContext: false); var files = (await keenFolder.GetFilesAsync().ConfigureAwait(continueOnCapturedContext: false)).ToList(); @@ -66,7 +65,7 @@ public async Task Add(CachedEvent e) if (null == e) throw new KeenException("Cached events may not be null"); - var keenFolder = await getKeenFolder() + var keenFolder = await GetKeenFolder() .ConfigureAwait(continueOnCapturedContext: false); IFile file; @@ -81,7 +80,7 @@ public async Task Add(CachedEvent e) // and generating and inserting a unique name within the lock. CreateFileAsync has // a CreateCollisionOption.GenerateUniqueName, but it will return the same name // multiple times when called from parallel tasks. - // If creating and writing the file fails, loop around and + // If creating and writing the file fails, loop around and generate a new name. if (string.IsNullOrEmpty(name)) lock (events) { @@ -115,12 +114,12 @@ await file.WriteAllTextAsync(content) // this when the queue is read than to try to dequeue the name. if (attempts > 100) throw new KeenException("Persistent failure while saving file, aborting", lastErr); - } while (!done); + } while (!done); } public async Task TryTake() { - var keenFolder = await getKeenFolder() + var keenFolder = await GetKeenFolder() .ConfigureAwait(continueOnCapturedContext: false); if (!events.Any()) return null; @@ -135,7 +134,7 @@ public async Task TryTake() .ConfigureAwait(continueOnCapturedContext: false); var ce = JObject.Parse(content); - var item = new CachedEvent((string)ce.SelectToken("Collection"), (JObject)ce.SelectToken("Event"), ce.SelectToken("Error").ToObject() ); + var item = new CachedEvent((string)ce.SelectToken("Collection"), (JObject)ce.SelectToken("Event"), ce.SelectToken("Error").ToObject()); await file.DeleteAsync() .ConfigureAwait(continueOnCapturedContext: false); return item; @@ -143,23 +142,22 @@ await file.DeleteAsync() public async Task Clear() { - var keenFolder = await getKeenFolder() + var keenFolder = await GetKeenFolder() .ConfigureAwait(continueOnCapturedContext: false); lock(events) events.Clear(); await keenFolder.DeleteAsync() .ConfigureAwait(continueOnCapturedContext: false); - await getKeenFolder() + await GetKeenFolder() .ConfigureAwait(continueOnCapturedContext: false); } - private static async Task getKeenFolder() + private static Task GetKeenFolder() { IFolder rootFolder = FileSystem.Current.LocalStorage; - var keenFolder = await rootFolder.CreateFolderAsync("KeenCache", CreationCollisionOption.OpenIfExists) - .ConfigureAwait(continueOnCapturedContext: false); - return keenFolder; - } + var keenFolderTask = rootFolder.CreateFolderAsync("KeenCache", CreationCollisionOption.OpenIfExists); + return keenFolderTask; + } } } diff --git a/Keen/EventCollection.cs b/Keen/EventCollection.cs index 647a48b..eb5fc24 100644 --- a/Keen/EventCollection.cs +++ b/Keen/EventCollection.cs @@ -1,100 +1,161 @@ using Newtonsoft.Json.Linq; using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; +using System.Threading.Tasks; + namespace Keen.Core { /// - /// EventCollection implements the IEventCollection interface which represents the Keen.IO EventCollection API methods. + /// EventCollection implements the IEventCollection interface which represents the Keen.IO + /// EventCollection API methods. /// internal class EventCollection : IEventCollection { - private string _serverUrl; - private IProjectSettings _prjSettings; + private readonly IKeenHttpClient _keenHttpClient; + private readonly string _eventsRelativeUrl; + private readonly string _readKey; + private readonly string _writeKey; + private readonly string _masterKey; + - public async System.Threading.Tasks.Task GetSchema(string collection) + internal EventCollection(IProjectSettings prjSettings, + IKeenHttpClientProvider keenHttpClientProvider) { - using (var client = new HttpClient()) + if (null == prjSettings) + { + throw new ArgumentNullException(nameof(prjSettings), + "Project Settings must be provided."); + } + + if (null == keenHttpClientProvider) { - client.DefaultRequestHeaders.Add("Authorization", _prjSettings.MasterKey); - client.DefaultRequestHeaders.Add("Keen-Sdk", KeenUtil.GetSdkVersion()); - - var responseMsg = await client.GetAsync(_serverUrl + collection) - .ConfigureAwait(continueOnCapturedContext: false); - var responseString = await responseMsg.Content.ReadAsStringAsync() - .ConfigureAwait(continueOnCapturedContext: false); - dynamic response = JObject.Parse(responseString); - - // error checking, throw an exception with information from the json - // response if available, then check the HTTP response. - KeenUtil.CheckApiErrorCode(response); - if (!responseMsg.IsSuccessStatusCode) - throw new KeenException("GetSchema failed with status: " + responseMsg.StatusCode); - - return response; + throw new ArgumentNullException(nameof(keenHttpClientProvider), + "A KeenHttpClient provider must be provided."); } + + if (string.IsNullOrWhiteSpace(prjSettings.KeenUrl) || + !Uri.IsWellFormedUriString(prjSettings.KeenUrl, UriKind.Absolute)) + { + throw new KeenException( + "A properly formatted KeenUrl must be provided via Project Settings."); + } + + var serverBaseUrl = new Uri(prjSettings.KeenUrl); + _keenHttpClient = keenHttpClientProvider.GetForUrl(serverBaseUrl); + _eventsRelativeUrl = KeenHttpClient.GetRelativeUrl(prjSettings.ProjectId, + KeenConstants.EventsResource); + + // TODO : It's possible we may want to change back to dynamically grabbing the keys + // from a stored IProjectSettings so client code can lazily assign keys. It creates a + // minor potential race condition, but will allow for scenarios like creating a + // KeenClient instance with only a master key in order to generate/acquire access keys + // to then set as the other keys. Otherwise a new KeenClient must be created or at + // least a new instance of the IEventCollection/IEvent/IQueries implementations. + + _readKey = prjSettings.ReadKey; + _writeKey = prjSettings.WriteKey; + _masterKey = prjSettings.MasterKey; } - public async System.Threading.Tasks.Task DeleteCollection(string collection) + + public async Task GetSchema(string collection) { - using (var client = new HttpClient()) + // TODO : So much of this code, both in the constructor and in the actual message + // dispatch, response parsing and error checking is copy/paste across Queries, Event + // and EventCollection everywhere we use KeenHttpClient. We could shove some of that + // into shared factory functionality (for the ctor stuff) and some of it into the + // KeenHttpClient (for the dispatch/response portions). + + + if (string.IsNullOrWhiteSpace(_readKey)) { - client.DefaultRequestHeaders.Add("Authorization", _prjSettings.MasterKey); - client.DefaultRequestHeaders.Add("Keen-Sdk", KeenUtil.GetSdkVersion()); + throw new KeenException("An API ReadKey is required to get collection schema."); + } + + var responseMsg = await _keenHttpClient + .GetAsync(GetCollectionUrl(collection), _readKey) + .ConfigureAwait(continueOnCapturedContext: false); + + var responseString = await responseMsg + .Content + .ReadAsStringAsync() + .ConfigureAwait(continueOnCapturedContext: false); - var responseMsg = await client.DeleteAsync(_serverUrl + collection) - .ConfigureAwait(continueOnCapturedContext: false); - if (!responseMsg.IsSuccessStatusCode) - throw new KeenException("DeleteCollection failed with status: " + responseMsg.StatusCode); + dynamic response = JObject.Parse(responseString); + + // error checking, throw an exception with information from the json + // response if available, then check the HTTP response. + KeenUtil.CheckApiErrorCode(response); + + if (!responseMsg.IsSuccessStatusCode) + { + throw new KeenException("GetSchema failed with status: " + responseMsg.StatusCode); } + + return response; } - public async System.Threading.Tasks.Task AddEvent(string collection, JObject anEvent) + public async Task DeleteCollection(string collection) { - var content = anEvent.ToString(); + if (string.IsNullOrWhiteSpace(_masterKey)) + { + throw new KeenException("An API MasterKey is required to delete a collection."); + } + + var responseMsg = await _keenHttpClient + .DeleteAsync(GetCollectionUrl(collection), _masterKey) + .ConfigureAwait(continueOnCapturedContext: false); - using (var client = new HttpClient()) - using (var contentStream = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(content)))) + if (!responseMsg.IsSuccessStatusCode) { - contentStream.Headers.Add("content-type", "application/json"); - - client.DefaultRequestHeaders.Add("Authorization", _prjSettings.WriteKey); - client.DefaultRequestHeaders.Add("Keen-Sdk", KeenUtil.GetSdkVersion()); - - var httpResponse = await client.PostAsync(_serverUrl + collection, contentStream) - .ConfigureAwait(continueOnCapturedContext: false); - var responseString = await httpResponse.Content.ReadAsStringAsync() - .ConfigureAwait(continueOnCapturedContext: false); - JObject jsonResponse = null; - try - { - // Normally the response content should be parsable JSON, - // but if the server returned a 404 error page or something - // like that, this will throw. - jsonResponse = JObject.Parse(responseString); - } - catch (Exception) - { } - - // error checking, throw an exception with information from the - // json response if available, then check the HTTP response. - KeenUtil.CheckApiErrorCode(jsonResponse); - if (!httpResponse.IsSuccessStatusCode) - throw new KeenException("AddEvent failed with status: " + httpResponse); + throw new KeenException("DeleteCollection failed with status: " + responseMsg.StatusCode); } } - public EventCollection(IProjectSettings prjSettings) + public async Task AddEvent(string collection, JObject anEvent) { - _prjSettings = prjSettings; + if (string.IsNullOrWhiteSpace(_writeKey)) + { + throw new KeenException("An API WriteKey is required to add events."); + } - _serverUrl = string.Format("{0}projects/{1}/{2}/", - _prjSettings.KeenUrl, _prjSettings.ProjectId, KeenConstants.EventsResource); + var content = anEvent.ToString(); + + var responseMsg = await _keenHttpClient + .PostAsync(GetCollectionUrl(collection), _writeKey, content) + .ConfigureAwait(continueOnCapturedContext: false); + + var responseString = await responseMsg + .Content + .ReadAsStringAsync() + .ConfigureAwait(continueOnCapturedContext: false); + + JObject jsonResponse = null; + + try + { + // Normally the response content should be parsable JSON, + // but if the server returned a 404 error page or something + // like that, this will throw. + jsonResponse = JObject.Parse(responseString); + } + catch (Exception) + { + } + + // error checking, throw an exception with information from the + // json response if available, then check the HTTP response. + KeenUtil.CheckApiErrorCode(jsonResponse); + + if (!responseMsg.IsSuccessStatusCode) + { + throw new KeenException("AddEvent failed with status: " + responseMsg.StatusCode); + } + } + + private string GetCollectionUrl(string collection) + { + return $"{_eventsRelativeUrl}/{collection}"; } } } diff --git a/Keen/HttpClientCache.cs b/Keen/HttpClientCache.cs new file mode 100644 index 0000000..3dd943c --- /dev/null +++ b/Keen/HttpClientCache.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; + + +namespace Keen.Core +{ + /// + /// An implementation of that caches HttpClient instances in + /// a dictionary mapping the base URL to a WeakReference to the actual instance. A PRO to this + /// approach is that HttpClient instances will automatically be evicted when no more strong + /// refs exist and the GC collects. A CON to using WeakReference, besides it not being generic + /// in the current version of the PCL and being a fairly heavyweight class, is that rapid + /// creation and releasing of owning instances like the KeenClient can still allow for the GC + /// to aggressively clean up HttpClient instances. Recommended usage of KeenClient shouldn't + /// make this a common problem, but at some point this cache can evolve to be more intelligent + /// about keeping instances alive deliberately. + /// + internal class HttpClientCache : IHttpClientProvider + { + // A singleton cache that can optionally be used and shared. If new caches need to be + // created, use the internal ctor. One use case for this might be to have multiple + // HttpClient instances with different configurations cached for the same URL for use in + // different sets of client modules. + internal static HttpClientCache Instance { get; } = new HttpClientCache(); + + // Explicit static constructor, no beforefieldinit + static HttpClientCache() { } + + + private readonly object _cacheLock; + + // NOTE : We should use ConcurrentDictionary> here. if/when we upgrade the PCL + // profile to something >= .NET 4.0. + + // NOTE : Use WeakReference in 4.5+ + private readonly IDictionary _httpClients; + + + // No external construction + internal HttpClientCache() + { + _cacheLock = new object(); + _httpClients = new Dictionary(); + } + + + /// + /// Retrieve an existing HttpClient for the given URL, or throw if it doesn't exist. + /// + /// The base URL the HttpClient is tied to. + /// The HttpClient which is expected to exist. + public HttpClient this[Uri baseUrl] + { + get + { + HttpClient httpClient = null; + + lock (_cacheLock) + { + WeakReference weakRef = null; + + if (!_httpClients.TryGetValue(baseUrl, out weakRef)) + { + throw new KeenException( + string.Format("No existing HttpClient for baseUrl \"{0}\"", baseUrl)); + } + + httpClient = weakRef.Target as HttpClient; + + if (null == httpClient) + { + throw new KeenException( + string.Format("Existing HttpClient for baseUrl \"{0}\" has been" + + "garbage collected.", baseUrl)); + } + } + + return httpClient; + } + } + + /// + /// Retrieve an existing HttpClient for the given URL, or create one with the given + /// handlers and headers. + /// + /// The base URL the HttpClient is tied to. + /// A factory function to create a handler chain. + /// Any headers that all requests to this URL should add by + /// default. + /// An HttpClient configured to handle requests for the given URL. + public HttpClient GetOrCreateForUrl( + Uri baseUrl, + Func getHandlerChain = null, + IEnumerable> defaultHeaders = null) + { + Action configure = null; + + if (null != defaultHeaders && Enumerable.Any(defaultHeaders)) + { + configure = (httpClientToConfigure) => + { + foreach (var header in defaultHeaders) + { + httpClientToConfigure.DefaultRequestHeaders.Add(header.Key, header.Value); + } + }; + } + + + HttpClient httpClient = GetOrCreateForUrl(baseUrl, getHandlerChain, configure); + + return httpClient; + } + + /// + /// Retrieve an existing HttpClient for the given URL, or create one with the given + /// handlers and configuration functor. + /// + /// The base URL the HttpClient is tied to. + /// A factory function to create a handler chain. + /// An action that takes the newly created HttpClient and + /// configures it however needed before it is stored and/or returned. + /// An HttpClient configured to handle requests for the given URL. + public HttpClient GetOrCreateForUrl( + Uri baseUrl, + Func getHandlerChain = null, + Action configure = null) + { + if (null == baseUrl) + { + throw new ArgumentNullException(nameof(baseUrl), + string.Format("Cannot use a null {0} as a key.", nameof(baseUrl))); + } + + HttpClient httpClient = null; + + lock (_cacheLock) + { + WeakReference weakRef = null; + + if (!_httpClients.TryGetValue(baseUrl, out weakRef) || + null == (httpClient = weakRef.Target as HttpClient)) + { + // If no handler chain is provided, a plain HttpClientHandler with no + // configuration whatsoever is installed. + httpClient = new HttpClient(getHandlerChain?.Invoke() ?? new HttpClientHandler()); + httpClient.BaseAddress = baseUrl; + + configure?.Invoke(httpClient); + + // Reuse the WeakReference if we already had an entry for this url. + if (null == weakRef) + { + _httpClients[baseUrl] = new WeakReference(httpClient); + } + else + { + weakRef.Target = httpClient; + } + } + } + + return httpClient; + } + + /// + /// Remove any cached HttpClients associated with the given URL. + /// + /// The base URL for which any cached HttpClient instances should + /// be purged. + public void RemoveForUrl(Uri baseUrl) + { + if (null == baseUrl) + { + throw new ArgumentNullException(nameof(baseUrl), + string.Format("Cannot use a null {0} as a key.", nameof(baseUrl))); + } + + lock (_cacheLock) + { + _httpClients.Remove(baseUrl); + } + } + + /// + /// Can this provider return an HttpClient instance for the given URL? For this + /// implementation, we'll check if an entry exists in the cache and if the WeakReference + /// is still valid and a strong ref can be taken. + /// + /// The base URL for which we'd like to know if an HttpClient can be + /// provided. + /// True if this provider could return an HttpClient for the given URL, false + /// otherwise. + public bool ExistsForUrl(Uri baseUrl) + { + if (null == baseUrl) + { + // We can't have null keys, so we wouldn't ever be able to look up this URL. + return false; + } + + lock (_cacheLock) + { + WeakReference weakRef = null; + bool exists = (_httpClients.TryGetValue(baseUrl, out weakRef) && + (null != weakRef.Target as HttpClient)); + + return exists; + } + } + + /// + /// Drop all HttpClient instances from the cache, no matter the URL. + /// + internal void Clear() + { + lock (_cacheLock) + { + _httpClients.Clear(); + } + } + + /// + /// Override the HttpClient provided for a given URL. This tests and replaces or inserts + /// all in one atomic operation. This will likely be useful for testing. + /// + /// URL to override. + /// HttpClient instance that will do the overriding. + internal void OverrideForUrl(Uri baseUrl, HttpClient httpClient) + { + lock (_cacheLock) + { + WeakReference weakRef = null; + + if (!_httpClients.TryGetValue(baseUrl, out weakRef)) + { + _httpClients[baseUrl] = new WeakReference(httpClient); + } + else + { + weakRef.Target = httpClient; + } + } + } + } +} diff --git a/Keen/IDynamicPropertyValue.cs b/Keen/IDynamicPropertyValue.cs index 2d594fc..9015095 100644 --- a/Keen/IDynamicPropertyValue.cs +++ b/Keen/IDynamicPropertyValue.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core { interface IDynamicPropertyValue diff --git a/Keen/IEvent.cs b/Keen/IEvent.cs index 3e85c79..568d4b2 100644 --- a/Keen/IEvent.cs +++ b/Keen/IEvent.cs @@ -1,11 +1,9 @@ using Keen.Core.EventCache; using Newtonsoft.Json.Linq; -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; + namespace Keen.Core { public interface IEvent diff --git a/Keen/IEventCache.cs b/Keen/IEventCache.cs index c88c54a..aaf3325 100644 --- a/Keen/IEventCache.cs +++ b/Keen/IEventCache.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading.Tasks; + namespace Keen.Core.EventCache { diff --git a/Keen/IEventCollection.cs b/Keen/IEventCollection.cs index 46ff892..dfb96c9 100644 --- a/Keen/IEventCollection.cs +++ b/Keen/IEventCollection.cs @@ -1,10 +1,7 @@ using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; + namespace Keen.Core { public interface IEventCollection diff --git a/Keen/IHttpClientProvider.cs b/Keen/IHttpClientProvider.cs new file mode 100644 index 0000000..e7db7d1 --- /dev/null +++ b/Keen/IHttpClientProvider.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + + +namespace Keen.Core +{ + /// + /// Represents a type that can provide an HttpClient for a given URL. It could act as a cache + /// by returning pre-existing instances, or create as necessary, or always create given the + /// optional configuration parameters pass in. + /// + public interface IHttpClientProvider + { + /// + /// Retrieve an existing HttpClient for the given URL. + /// + /// The base URL the HttpClient is tied to. + /// The HttpClient which is expected to exist. + HttpClient this[Uri baseUrl] { get; } + + /// + /// Retrieve an existing HttpClient for the given URL, or create one with the given + /// handlers and configuration functor. + /// + /// The base URL the HttpClient is tied to. + /// A factory function to create a handler chain. + /// Any headers that all requests to this URL should add by + /// default. + /// An HttpClient configured to handle requests for the given URL. + HttpClient GetOrCreateForUrl( + Uri baseUrl, + Func getHandlerChain = null, + IEnumerable> defaultHeaders = null + ); + + /// + /// Retrieve an existing HttpClient for the given URL, or create one with the given + /// handlers and configuration functor. + /// + /// The base URL the HttpClient is tied to. + /// A factory function to create a handler chain. + /// An action that takes the newly created HttpClient and + /// configures it however needed before it is stored and/or returned. + /// An HttpClient configured to handle requests for the given URL. + HttpClient GetOrCreateForUrl(Uri baseUrl, + Func getHandlerChain = null, + Action configure = null); + + /// + /// If caching instances, remove any associated with the given URL. + /// + /// The base URL for which any cached HttpClient instances should + /// be purged. + void RemoveForUrl(Uri baseUrl); + + /// + /// Can this provider return an HttpClient instance for the given URL? + /// + /// The base URL for which we'd like to know if an HttpClient can be + /// provided. + /// True if this provider could return an HttpClient for the given URL, false + /// otherwise. + bool ExistsForUrl(Uri baseUrl); + } +} \ No newline at end of file diff --git a/Keen/IKeenHttpClient.cs b/Keen/IKeenHttpClient.cs new file mode 100644 index 0000000..88d3d83 --- /dev/null +++ b/Keen/IKeenHttpClient.cs @@ -0,0 +1,74 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + + +namespace Keen.Core +{ + /// + /// Represents a type capable of performing HTTP operations destined for a Keen API endpoint. + /// This should augment and/or alter normal HttpClient behavior where appropriate taking into + /// consideration Keen-specific protocols. + /// + public interface IKeenHttpClient + { + /// + /// Create and send a GET request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to GET. Must be properly formatted as a + /// relative Uri. + /// The key to use for authenticating this request. + /// The response message. + Task GetAsync(string resource, string authKey); + + /// + /// Create and send a GET request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to GET. + /// The key to use for authenticating this request. + /// The response message. + Task GetAsync(Uri resource, string authKey); + + /// + /// Create and send a POST request with the given content to the given relative resource + /// using the given key for authentication. + /// + /// The relative resource to POST. Must be properly formatted as a + /// relative Uri. + /// The key to use for authenticating this request. + /// The POST body to send. + /// The response message. + Task PostAsync(string resource, string authKey, string content); + + /// + /// Create and send a POST request with the given content to the given relative resource + /// using the given key for authentication. + /// + /// The relative resource to POST. + /// The key to use for authenticating this request. + /// The POST body to send. + /// The response message. + Task PostAsync(Uri resource, string authKey, string content); + + /// + /// Create and send a DELETE request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to DELETE. Must be properly formatted as a + /// relative Uri. + /// The key to use for authenticating this request. + /// The response message. + Task DeleteAsync(string resource, string authKey); + + /// + /// Create and send a DELETE request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to DELETE. + /// The key to use for authenticating this request. + /// The response message. + Task DeleteAsync(Uri resource, string authKey); + } +} diff --git a/Keen/IKeenHttpClientProvider.cs b/Keen/IKeenHttpClientProvider.cs new file mode 100644 index 0000000..6212b74 --- /dev/null +++ b/Keen/IKeenHttpClientProvider.cs @@ -0,0 +1,23 @@ +using System; + + +namespace Keen.Core +{ + /// + /// An instance of this type can provide an to be used to perform + /// requests against a given URL. Implement to customize how other parts of the SDK dispatch + /// requests to a keen IO endpoint. + /// + public interface IKeenHttpClientProvider + { + /// + /// Given a base URL, return an IKeenHttpClient against which requests can be made. The + /// intent is that all requests using this IKeenHttpClient will be to resources relative to + /// this base URL. It is expected that this IKeenHttpClient is thread-safe. + /// + /// The base URL, e.g. https://api.keen.io/3.0/ + /// An IKeenHttpClient configured to handle requests to resources relative to the + /// given base URL. + IKeenHttpClient GetForUrl(Uri baseUrl); + } +} diff --git a/Keen/IProjectSettings.cs b/Keen/IProjectSettings.cs index bea4c3c..45ed7a3 100644 --- a/Keen/IProjectSettings.cs +++ b/Keen/IProjectSettings.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core { /// @@ -12,7 +8,10 @@ public interface IProjectSettings { /// /// The Keen.IO URL for this project. Usually this will be the - /// server address and API version. + /// server address and API version. This should end with a '/'. + /// + /// - e.g. https://api.keen.io/3.0/ + /// /// string KeenUrl { get; } diff --git a/Keen/Keen.csproj b/Keen/Keen.csproj index 645e77a..f2c4092 100644 --- a/Keen/Keen.csproj +++ b/Keen/Keen.csproj @@ -43,10 +43,17 @@ Properties\SharedVersionInfo.cs + + + + + + + diff --git a/Keen/KeenClient.cs b/Keen/KeenClient.cs index 1ba9557..00ba880 100644 --- a/Keen/KeenClient.cs +++ b/Keen/KeenClient.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; + namespace Keen.Core { /// @@ -16,7 +17,7 @@ namespace Keen.Core public class KeenClient { private readonly IProjectSettings _prjSettings; - private readonly Dictionary _globalProperties = new Dictionary(); + private readonly IDictionary _globalProperties = new Dictionary(); /// /// EventCollection provides direct access to the Keen.IO EventCollection API methods. @@ -35,7 +36,7 @@ public class KeenClient /// /// EventCache provides a caching implementation allowing events to be cached locally /// instead of being sent one at a time. It is not normally necessary to use this property. - /// The implementation is responsible for cache maintenance policy, such as trimming + /// The implementation is responsible for cache maintenance policy, such as trimming /// old entries to avoid excessive cache size. /// public IEventCache EventCache { get; private set; } @@ -83,11 +84,9 @@ private void ExecDynamicPropertyValue(string propName, IDynamicPropertyValue dyn throw new KeenException(string.Format("Dynamic property \"{0}\" execution returned null", propName)); } - /// - /// - /// - /// A ProjectSettings instance containing the ProjectId and API keys - public KeenClient(IProjectSettings prjSettings) + private KeenClient(IProjectSettings prjSettings, + IEventCache eventCache, + IKeenHttpClientProvider keenHttpClientProvider) { // Preconditions if (null == prjSettings) @@ -102,12 +101,30 @@ public KeenClient(IProjectSettings prjSettings) throw new KeenException("A URL for the server address is required."); _prjSettings = prjSettings; - // The EventCollection and Event interface normally should not need to - // be set by callers, so the default implementation is set up here. Users - // may override these by injecting an implementation via the property. - EventCollection = new EventCollection(_prjSettings); - Event = new Event(_prjSettings); - Queries = new Queries(_prjSettings); + + if (null != eventCache) + { + EventCache = eventCache; + } + + // Use the default provider if none was passed in. + keenHttpClientProvider = (keenHttpClientProvider ?? new KeenHttpClientProvider()); + + // These interfaces normally should not need to be set by client code, so the default + // implementation is set up here. These may be overridden by injecting an + // implementation via their respective properties. + EventCollection = new EventCollection(_prjSettings, keenHttpClientProvider); + Event = new Event(_prjSettings, keenHttpClientProvider); + Queries = new Queries(_prjSettings, keenHttpClientProvider); + } + + /// + /// + /// + /// A ProjectSettings instance containing the ProjectId and API keys + public KeenClient(IProjectSettings prjSettings) + : this(prjSettings, null, null) + { } /// @@ -116,9 +133,14 @@ public KeenClient(IProjectSettings prjSettings) /// A ProjectSettings instance containing the ProjectId and API keys /// An IEventCache instance providing a caching strategy public KeenClient(IProjectSettings prjSettings, IEventCache eventCache) - : this(prjSettings) + : this(prjSettings, eventCache, null) + { + } + + public KeenClient(IProjectSettings prjSettings, + IKeenHttpClientProvider keenHttpClientProvider) + : this(prjSettings, null, keenHttpClientProvider) { - EventCache = eventCache; } /// @@ -161,8 +183,8 @@ public void DeleteCollection(string collection) public async Task GetSchemasAsync() { // Preconditions - if (string.IsNullOrWhiteSpace(_prjSettings.MasterKey)) - throw new KeenException("Master API key is required for GetSchemas"); + if (string.IsNullOrWhiteSpace(_prjSettings.ReadKey)) + throw new KeenException("Read API key is required for GetSchemas"); return await Event.GetSchemas() .ConfigureAwait(continueOnCapturedContext: false); @@ -176,7 +198,7 @@ public JArray GetSchemas() { try { - return Event.GetSchemas().Result; + return GetSchemasAsync().Result; } catch (AggregateException ex) { @@ -193,8 +215,8 @@ public async Task GetSchemaAsync(string collection) { // Preconditions KeenUtil.ValidateEventCollectionName(collection); - if (string.IsNullOrWhiteSpace(_prjSettings.MasterKey)) - throw new KeenException("Master API key is required for GetSchema"); + if (string.IsNullOrWhiteSpace(_prjSettings.ReadKey)) + throw new KeenException("Read API key is required for GetSchema"); return await EventCollection.GetSchema(collection) .ConfigureAwait(continueOnCapturedContext: false); @@ -318,7 +340,7 @@ public async Task AddEventAsync(string collection, object eventInfo, IEnumerable var jEvent = PrepareUserObject(eventInfo, addOns); - // If an event cache has been provided, cache this event insead of sending it. + // If an event cache has been provided, cache this event instead of sending it. if (null != EventCache) await EventCache.Add(new CachedEvent(collection, jEvent)) .ConfigureAwait(false); @@ -397,7 +419,7 @@ public void AddEvent(string collection, object eventInfo, IEnumerable add } /// - /// Submit all events found in the event cache. If an events are rejected by the server, + /// Submit all events found in the event cache. If any events are rejected by the server, /// KeenCacheException will be thrown with a listing of the rejected events, each with /// the error message it received. /// @@ -415,7 +437,7 @@ public void SendCachedEvents() } /// - /// Submit all events found in the event cache. If an events are rejected by the server, + /// Submit all events found in the event cache. If any events are rejected by the server, /// KeenCacheException will be thrown with a listing of the rejected events, each with /// the error message it received. /// @@ -472,11 +494,6 @@ public async Task>> GetQueries() return await Queries.AvailableQueries(); } - - - - - /// /// Call any Keen.IO API function with the specified parameters. /// @@ -488,7 +505,6 @@ public async Task QueryAsync(string queryName, Dictionary /// Call any Keen.IO API function with the specified parameters. Refer to Keen API documentation for /// details of request parameters and return type. Return type may be cast as dynamic. @@ -954,6 +970,5 @@ public IEnumerable + /// Helps with performing HTTP operations destined for a Keen API endpoint. Helper methods in + /// this class will add appropriate headers and config to use the underlying HttpClient + /// in the way expected by the Keen IO API. This class should be long-lived and all public + /// methods are thread-safe, so ideal usage is to configure it once for a given base URL and + /// reuse it with relative resources to send requests for the duration of the app or module. + /// + internal class KeenHttpClient : IKeenHttpClient + { + private static readonly string JSON_CONTENT_TYPE = "application/json"; + private static readonly string AUTH_HEADER_KEY = "Authorization"; + + + // We don't destroy this manually. Whatever code provides the HttpClient directly or via an + // IHttpClientProvider should be sure to handle its lifetime. + private readonly HttpClient _httpClient = null; + + + internal KeenHttpClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + internal static string GetRelativeUrl(string projectId, string resource) + { + return $"projects/{projectId}/{resource}"; + } + + /// + /// Create and send a GET request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to GET. Must be properly formatted as a + /// relative Uri. + /// The key to use for authenticating this request. + /// >The response message. + public Task GetAsync(string resource, string authKey) + { + var url = new Uri(resource, UriKind.Relative); + + return GetAsync(url, authKey); + } + + /// + /// Create and send a GET request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to GET. + /// The key to use for authenticating this request. + /// >The response message. + public Task GetAsync(Uri resource, string authKey) + { + KeenHttpClient.RequireAuthKey(authKey); + + HttpRequestMessage get = CreateRequest(HttpMethod.Get, resource, authKey); + + return _httpClient.SendAsync(get); + } + + // TODO : Instead of (or in addition to) string, also accept HttpContent content and/or + // JObject content? + + /// + /// Create and send a POST request with the given content to the given relative resource + /// using the given key for authentication. + /// + /// The relative resource to POST. Must be properly formatted as a + /// relative Uri. + /// The key to use for authenticating this request. + /// The POST body to send. + /// >The response message. + public Task PostAsync(string resource, string authKey, string content) + { + var url = new Uri(resource, UriKind.Relative); + + return PostAsync(url, authKey, content); + } + + /// + /// Create and send a POST request with the given content to the given relative resource + /// using the given key for authentication. + /// + /// The relative resource to POST. + /// The key to use for authenticating this request. + /// The POST body to send. + /// >The response message. + public async Task PostAsync(Uri resource, + string authKey, + string content) + { + KeenHttpClient.RequireAuthKey(authKey); + + if (string.IsNullOrWhiteSpace(content)) + { + // Technically, we can encode an empty string or whitespace, but why? For now + // we use GET for querying. If we ever need to POST with no content, we should + // reorganize the logic below to never create/set the content stream. + throw new ArgumentNullException(nameof(content), "Unexpected empty content."); + } + + // If we switch PCL profiles, instead use MediaTypeFormatters (or ObjectContent)?, + // like here?: https://msdn.microsoft.com/en-us/library/system.net.http.httpclientextensions.putasjsonasync(v=vs.118).aspx + using (var contentStream = + new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(content)))) + { + // TODO : Amake sure this is the same as Add("content-type", "application/json") + contentStream.Headers.ContentType = + new MediaTypeHeaderValue(KeenHttpClient.JSON_CONTENT_TYPE); + + HttpRequestMessage post = CreateRequest(HttpMethod.Post, resource, authKey); + post.Content = contentStream; + + return await _httpClient.SendAsync(post).ConfigureAwait(false); + + // TODO : Should we do the KeenUtil.CheckApiErrorCode() here? + // TODO : Should we check the if (!responseMsg.IsSuccessStatusCode) here too? + // TODO : If we centralize error checking in this class we could have variations + // of these helpers that return string or JToken or JArray or JObject. It might + // also be nice for those options to optionally hand back the raw + // HttpResponseMessage in an out param if desired? + // TODO : Use CallerMemberNameAttribute to print error messages? + // http://stackoverflow.com/questions/3095696/how-do-i-get-the-calling-method-name-and-type-using-reflection?noredirect=1&lq=1 + } + } + + /// + /// Create and send a DELETE request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to DELETE. Must be properly formatted as a + /// relative Uri. + /// The key to use for authenticating this request. + /// The response message. + public Task DeleteAsync(string resource, string authKey) + { + var url = new Uri(resource, UriKind.Relative); + + return DeleteAsync(url, authKey); + } + + /// + /// Create and send a DELETE request to the given relative resource using the given key for + /// authentication. + /// + /// The relative resource to DELETE. + /// The key to use for authenticating this request. + /// The response message. + public Task DeleteAsync(Uri resource, string authKey) + { + KeenHttpClient.RequireAuthKey(authKey); + + HttpRequestMessage delete = CreateRequest(HttpMethod.Delete, resource, authKey); + + return _httpClient.SendAsync(delete); + } + + private static HttpRequestMessage CreateRequest(HttpMethod verb, + Uri resource, + string authKey) + { + var request = new HttpRequestMessage() + { + RequestUri = resource, + Method = verb + }; + + request.Headers.Add(KeenHttpClient.AUTH_HEADER_KEY, authKey); + + return request; + } + + private static void RequireAuthKey(string authKey) + { + if (string.IsNullOrWhiteSpace(authKey)) + { + throw new ArgumentNullException(nameof(authKey), "Auth key is required."); + } + } + } +} diff --git a/Keen/KeenHttpClientFactory.cs b/Keen/KeenHttpClientFactory.cs new file mode 100644 index 0000000..191029c --- /dev/null +++ b/Keen/KeenHttpClientFactory.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + + +namespace Keen.Core +{ + /// + /// A set of factory methods to help in creating see cref="IKeenHttpClient"/> instances. These + /// are useful when implementing see cref="IKeenHttpClientProvider"/> so that the constructed + /// instances have the right mix of default and custom configuration. + /// + public static class KeenHttpClientFactory + { + private static readonly IEnumerable> DEFAULT_HEADERS = + new[] { new KeyValuePair("Keen-Sdk", KeenUtil.GetSdkVersion()) }; + + + private class LoggingHttpHandler : DelegatingHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // TODO : Log stuff before and after request, then move to its own file. + + // Now dispatch to the inner handler via the base impl. + return base.SendAsync(request, cancellationToken); + } + } + + + private static HttpMessageHandler CreateHandlerChainInternal( + HttpClientHandler innerHandler, + IEnumerable handlers) + { + // NOTE : There is no WebProxy available to the PCL profile, so we have to create an + // IWebProxy implementation manually. Proxy is only supported on HttpClientHandler, and + // not directly on DelegatingHandler, so handle that too. Basically only support Proxy + // if client code does *not* give us an HttpClientHandler. Or else set the Proxy on + // their handler, but make sure it's not already set. + + // Example of how setting Proxy works in big .NET where the WebProxy class exists: + // + // new HttpClientHandler() + // { + // Proxy = WebProxy("http://localhost:8888", false), + // UseProxy = true + // }; + + + // TODO : Also, to support Proxy, we have to realize we'd be turning it on for a given + // HttpClientHandler already installed for the HttpClient in the cache for a given URL. + // Since modifications aren't allowed for HttpClients/*Handlers, we would replace the + // HttpClient, which would affect all future users of the cache requesting an + // HttpClient for that URL, when really we want an abstraction to keep that sort of + // setting tied to, which maybe is this KeenHttpClient? + + + HttpMessageHandler handlerChain = (innerHandler ?? new HttpClientHandler()); + + if (null == handlers) + { + return handlerChain; + } + + foreach (var handler in handlers.Reverse()) + { + if (null == handler) + { + throw new ArgumentNullException(nameof(handlers), + "One of the given DelegatingHandler params was null."); + } + + if (null != handler.InnerHandler) + { + throw new ArgumentException("Encountered a non-null InnerHandler in handler " + + "chain, which would be overwritten.", + nameof(handlers)); + } + + // This will throw if the given handler has already started any requests. + // Basically all properties on all HttpClient/*Handler variations call + // CheckDisposedOrStarted() in any setter, so the entire HttpClient is pretty much + // locked down once it starts getting used. + handler.InnerHandler = handlerChain; + handlerChain = handler; + } + + return handlerChain; + } + + private static IEnumerable CreateDefaultDelegatingHandlers() + { + // TODO : Put more custom handlers in here, like retry/failure/proxy/logging handlers. + + // Create these every time, since *Handlers can't have properties changed after they've + // started handling requests for an HttpClient. + return new[] { new LoggingHttpHandler() }; + } + + /// + /// Create the default handler pipeline with only Keen internal handlers installed. + /// + /// returns>The default handler chain. + public static HttpMessageHandler CreateDefaultHandlerChain() + { + return KeenHttpClientFactory.CreateHandlerChainInternal( + null, + KeenHttpClientFactory.CreateDefaultDelegatingHandlers()); + } + + /// + /// Create an HttpMessageHandler representing the handler pipeline. We will construct the + /// HTTP handler pipeline such that provided handlers are called in order for requests, and + /// receive responses in reverse order. Keen internal handlers will defer to the first + /// DelegatingHandler and the pipeline will terminate at our HttpClientHandler. + /// + /// Handlers to be chained in the pipeline. + /// returns>The entire handler chain. + public static HttpMessageHandler CreateHandlerChain(params DelegatingHandler[] handlers) + { + return KeenHttpClientFactory.CreateHandlerChain(null, handlers); + } + + /// + /// Create an HttpMessageHandler representing the handler pipeline. We will construct the + /// HTTP handler pipeline such that provided handlers are called in order for requests, and + /// receive responses in reverse order. Keen internal handlers will defer to the first + /// DelegatingHandler and the pipeline will terminate at our HttpClientHandler or to the + /// given HttpClientHandler if present, in case client code wants to do something like use + /// WebRequestHandler functionality or otherwise add custom behavior. + /// + /// Terminating HttpClientHandler. + /// Handlers to be chained in the pipeline. + /// The entire handler chain. + public static HttpMessageHandler CreateHandlerChain(HttpClientHandler innerHandler, + params DelegatingHandler[] handlers) + { + // We put our handlers first. Client code can look at the final state of the request + // this way. Overwriting built-in handler state is shooting oneself in the foot. + IEnumerable intermediateHandlers = + KeenHttpClientFactory.CreateDefaultDelegatingHandlers().Concat(handlers); + + return KeenHttpClientFactory.CreateHandlerChainInternal(innerHandler, + intermediateHandlers); + } + + // NOTE : BaseUrl should have a final slash or the last Uri part is discarded. Also, + // relative urls can *not* start with a slash. + + // Not exposed so that 3rd party code doesn't accidentally build a KeenHttpClient without + // our handlers installed, which wouldn't be ideal. + private static KeenHttpClient Create(Uri baseUrl, + IHttpClientProvider httpClientProvider, + Func getHandlerChain) + { + if (!baseUrl.IsAbsoluteUri) + { + throw new ArgumentException( + "The given base Url must be in the form of an absolute Uri.", + nameof(baseUrl)); + } + + // Delay actual creation of the handler chain by passing in a Func<> to create it. This + // way if HttpClient already exists, we won't bother creating/modifying handlers. + var httpClient = httpClientProvider.GetOrCreateForUrl( + baseUrl, + getHandlerChain, + KeenHttpClientFactory.DEFAULT_HEADERS); + + var newClient = new KeenHttpClient(httpClient); + + return newClient; + } + + /// + /// Construct an IKeenHttpClient for the given base URL, configured with an HttpClient that + /// is retrieved and/or stored in the given IHttpClientProvider. If necessary, the + /// HttpClient is created and configured with the default set of HTTP handlers. + /// + /// + /// + /// + /// The base URL for the constructed IKeenHttpClient. + /// The provider used to retrieve the HttpClient. + /// A new IKeenHttpClient for the given base URL. + public static IKeenHttpClient Create(Uri baseUrl, IHttpClientProvider httpClientProvider) + { + return KeenHttpClientFactory.Create( + baseUrl, + httpClientProvider, + () => KeenHttpClientFactory.CreateDefaultHandlerChain()); + } + + /// + /// Construct an IKeenHttpClient for the given base URL, configured with an HttpClient that + /// is retrieved and/or stored in the given IHttpClientProvider, and if necessary, + /// configured with the given HTTP handlers. + /// + /// + /// + /// + /// The base URL for the constructed IKeenHttpClient. + /// The provider used to retrieve the HttpClient. + /// HTTP handler terminating the handler chain. + /// Handlers to be chained in the pipeline. + /// A new IKeenHttpClient for the given base URL. + public static IKeenHttpClient Create(Uri baseUrl, + IHttpClientProvider httpClientProvider, + HttpClientHandler innerHandler, + params DelegatingHandler[] handlers) + { + return KeenHttpClientFactory.Create( + baseUrl, + httpClientProvider, + () => KeenHttpClientFactory.CreateHandlerChain(innerHandler, handlers)); + } + + /// + /// Construct an IKeenHttpClient for the given base URL, configured with an HttpClient that + /// is retrieved and/or stored in the given IHttpClientProvider, and if necessary, + /// configured with the given HTTP handlers in a lazy fashion only if construction is + /// necessary. Note that the given handler factory function could be called under a lock, + /// so care should be taken in multi-threaded scenarios. + /// + /// + /// + /// + /// The base URL for the constructed IKeenHttpClient. + /// The provider used to retrieve the HttpClient. + /// A factory function called if construction of the HttpClient + /// is necessary. It should return an optional HttpClientHandler to terminate the + /// handler chain, as well as an optional list of intermediate HTTP handlers to be + /// chained in the pipeline. + /// A new IKeenHttpClient for the given base URL. + public static IKeenHttpClient Create( + Uri baseUrl, + IHttpClientProvider httpClientProvider, + Func>> getHandlers) + { + Func getHandlerChain = () => + { + Tuple> handlers = + getHandlers?.Invoke(); + + return KeenHttpClientFactory.CreateHandlerChainInternal(handlers.Item1, + handlers.Item2); + }; + + return KeenHttpClientFactory.Create( + baseUrl, + httpClientProvider, + getHandlerChain); + } + } +} diff --git a/Keen/KeenHttpClientProvider.cs b/Keen/KeenHttpClientProvider.cs new file mode 100644 index 0000000..2703d8b --- /dev/null +++ b/Keen/KeenHttpClientProvider.cs @@ -0,0 +1,26 @@ +using System; + + +namespace Keen.Core +{ + /// + /// An implementation of that uses the default + /// creation logic and relies on the + /// class as an . + /// + internal class KeenHttpClientProvider : IKeenHttpClientProvider + { + /// + /// Given a base URL, return an IKeenHttpClient against which requests can be made. + /// + /// The base URL, e.g. https://api.keen.io/3.0/ + /// An IKeenHttpClient configured to handle requests to resources relative to the + /// given base URL. + public IKeenHttpClient GetForUrl(Uri baseUrl) + { + var keenHttpClient = KeenHttpClientFactory.Create(baseUrl, HttpClientCache.Instance); + + return keenHttpClient; + } + } +} diff --git a/Keen/KeenUtil.cs b/Keen/KeenUtil.cs index 656827f..07005ca 100644 --- a/Keen/KeenUtil.cs +++ b/Keen/KeenUtil.cs @@ -2,13 +2,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; -using System.Net.Http; using System.Reflection; -using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; + namespace Keen.Core { @@ -25,7 +22,7 @@ static KeenUtil() /// /// Retrieve a string representing the current version of the Keen IO SDK, as defined by - /// the AssemblyInformationVersion. + /// the AssemblyInformationalVersion. /// /// The SDK version string. public static string GetSdkVersion() @@ -64,18 +61,6 @@ public static string ToSafeString(this object obj) return (obj ?? string.Empty).ToString(); } - public static int? TryGetInt(this string s) - { - int i; - return int.TryParse(s, out i) ? (int?)i : null; - } - - public static double? TryGetDouble(this string s) - { - double i; - return double.TryParse(s, out i) ? (double?)i : null; - } - /// /// Apply property name restrictions. Throws KeenException with an /// explanation if a collection name is unacceptable. @@ -173,7 +158,6 @@ public static Exception GetBulkApiError(JObject apiResponse) } } - /// /// Check the 'error_code' field and throw the appropriate exception if non-null. /// @@ -181,8 +165,8 @@ public static Exception GetBulkApiError(JObject apiResponse) public static void CheckApiErrorCode(dynamic apiResponse) { if (apiResponse is JArray) return; - - var errorCode = (string) apiResponse.SelectToken("$.error_code"); + + var errorCode = (string)apiResponse.SelectToken("$.error_code"); if (errorCode != null) { diff --git a/Keen/ProjectSettingsProvider.cs b/Keen/ProjectSettingsProvider.cs index 1a9eb0b..9fe044c 100644 --- a/Keen/ProjectSettingsProvider.cs +++ b/Keen/ProjectSettingsProvider.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; - + namespace Keen.Core { public class ProjectSettingsProvider : IProjectSettings @@ -38,11 +33,13 @@ public class ProjectSettingsProvider : IProjectSettings /// /// Obtains project setting values as constructor parameters /// - /// Base Keen.IO service URL, required + /// /// Keen project id, required - /// Master API key, required for getting schema or deleting collections + /// Master API key, required for certain operations, such as + /// getting schema or deleting collections /// Write API key, required for inserting events - /// Read API key + /// Read API key, required for performing queries + /// Base Keen.IO service URL public ProjectSettingsProvider(string projectId, string masterKey = "", string writeKey = "", string readKey = "", string keenUrl = null) { KeenUrl = keenUrl ?? KeenConstants.ServerAddress + "/" + KeenConstants.ApiVersion + "/"; diff --git a/Keen/Properties/AssemblyInfo.cs b/Keen/Properties/AssemblyInfo.cs index fd43530..66bd3a7 100644 --- a/Keen/Properties/AssemblyInfo.cs +++ b/Keen/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; // Friendly name and description for this assembly. @@ -8,4 +9,6 @@ [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Keen.Net.Test")] + // See also: SharedAssemblyInfo.cs and SharedVersionInfo.cs diff --git a/Keen/Query/IQueries.cs b/Keen/Query/IQueries.cs index 71565e8..9d8b9c2 100644 --- a/Keen/Query/IQueries.cs +++ b/Keen/Query/IQueries.cs @@ -1,10 +1,8 @@ using Newtonsoft.Json.Linq; -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; + namespace Keen.Core.Query { public interface IQueries diff --git a/Keen/Query/MultiAnalysisParam.cs b/Keen/Query/MultiAnalysisParam.cs index 002b719..e62da35 100644 --- a/Keen/Query/MultiAnalysisParam.cs +++ b/Keen/Query/MultiAnalysisParam.cs @@ -1,9 +1,4 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core.Query { public sealed class MultiAnalysisParam @@ -36,7 +31,7 @@ public sealed class Metric /// A user defined string that acts as a name for the analysis. /// This will be returned in the results so the various analyses are easily identifiable. /// The metric type. - public MultiAnalysisParam( string label, Metric analysis) + public MultiAnalysisParam(string label, Metric analysis) { Label = label; Analysis = analysis; diff --git a/Keen/Query/Queries.cs b/Keen/Query/Queries.cs index 6e2b772..b4821e9 100644 --- a/Keen/Query/Queries.cs +++ b/Keen/Query/Queries.cs @@ -2,14 +2,10 @@ using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Dynamic; -using System.IO; using System.Linq; -using System.Net.Http; -using System.Text; using System.Threading.Tasks; + namespace Keen.Core.Query { /// @@ -17,54 +13,88 @@ namespace Keen.Core.Query /// internal class Queries : IQueries { - private IProjectSettings _prjSettings; - private string _serverUrl; + private readonly IKeenHttpClient _keenHttpClient; + private readonly string _queryRelativeUrl; + private readonly string _key; + - public Queries(IProjectSettings prjSettings) + internal Queries(IProjectSettings prjSettings, + IKeenHttpClientProvider keenHttpClientProvider) { - _prjSettings = prjSettings; + if (null == prjSettings) + { + throw new ArgumentNullException(nameof(prjSettings), + "Project Settings must be provided."); + } - _serverUrl = string.Format("{0}projects/{1}/{2}", - _prjSettings.KeenUrl, _prjSettings.ProjectId, KeenConstants.QueriesResource); - } + if (null == keenHttpClientProvider) + { + throw new ArgumentNullException(nameof(keenHttpClientProvider), + "A KeenHttpClient provider must be provided."); + } + if (string.IsNullOrWhiteSpace(prjSettings.KeenUrl) || + !Uri.IsWellFormedUriString(prjSettings.KeenUrl, UriKind.Absolute)) + { + throw new KeenException( + "A properly formatted KeenUrl must be provided via Project Settings."); + } + + var serverBaseUrl = new Uri(prjSettings.KeenUrl); + _keenHttpClient = keenHttpClientProvider.GetForUrl(serverBaseUrl); + _queryRelativeUrl = KeenHttpClient.GetRelativeUrl(prjSettings.ProjectId, + KeenConstants.QueriesResource); + + // TODO : The Python SDK has changed to not automatically falling back, but rather + // throwing so that client devs learn to use the most appropriate key. So here we + // really could or should just demand the ReadKey. + _key = string.IsNullOrWhiteSpace(prjSettings.MasterKey) ? + prjSettings.ReadKey : prjSettings.MasterKey; + } - private async Task KeenWebApiRequest(string operation = "", Dictionary parms = null) + private async Task KeenWebApiRequest(string operation = "", + Dictionary parms = null) { - // Either an API read key or a master key is required - var key = string.IsNullOrWhiteSpace(_prjSettings.MasterKey) ? _prjSettings.ReadKey : _prjSettings.MasterKey; - if (string.IsNullOrWhiteSpace(key)) - throw new KeenException("An API ReadKey or MasterKey is required"); + if (string.IsNullOrWhiteSpace(_key)) + { + throw new KeenException("An API ReadKey or MasterKey is required."); + } - var parmVals = parms == null ? "" : string.Join("&", from p in parms.Keys - where !string.IsNullOrEmpty(parms[p]) - select string.Format("{0}={1}", p, Uri.EscapeDataString(parms[p]))); + var parmVals = (parms == null) ? + "" : string.Join("&", from p in parms.Keys + where !string.IsNullOrEmpty(parms[p]) + select string.Format("{0}={1}", + p, + Uri.EscapeDataString(parms[p]))); var url = string.Format("{0}{1}{2}", - _serverUrl, - string.IsNullOrWhiteSpace(operation) ? "" : "/" + operation, - string.IsNullOrWhiteSpace(parmVals) ? "" : "?" + parmVals); + _queryRelativeUrl, + string.IsNullOrWhiteSpace(operation) ? "" : "/" + operation, + string.IsNullOrWhiteSpace(parmVals) ? "" : "?" + parmVals); - using (var client = new HttpClient()) - { - client.DefaultRequestHeaders.Add("Authorization", key); - client.DefaultRequestHeaders.Add("Keen-Sdk", KeenUtil.GetSdkVersion()); + var responseMsg = await _keenHttpClient.GetAsync(url, _key).ConfigureAwait(false); - var responseMsg = await client.GetAsync(url).ConfigureAwait(false); - var responseString = await responseMsg.Content.ReadAsStringAsync().ConfigureAwait(false); - var response = JObject.Parse(responseString); + var responseString = await responseMsg + .Content + .ReadAsStringAsync() + .ConfigureAwait(false); - // error checking, throw an exception with information from the json - // response if available, then check the HTTP response. - KeenUtil.CheckApiErrorCode((dynamic)response); - if (!responseMsg.IsSuccessStatusCode) - throw new KeenException("Request failed with status: " + responseMsg.StatusCode); + var response = JObject.Parse(responseString); - return response; + // error checking, throw an exception with information from the json + // response if available, then check the HTTP response. + KeenUtil.CheckApiErrorCode(response); + + if (!responseMsg.IsSuccessStatusCode) + { + throw new KeenException("Request failed with status: " + + responseMsg.StatusCode); } + + return response; } - public async Task>> AvailableQueries() + public async Task>> AvailableQueries() { var reply = await KeenWebApiRequest().ConfigureAwait(false); return from j in reply.Children() @@ -75,7 +105,6 @@ public async Task>> AvailableQueries() #region metric - public async Task Metric(string queryName, Dictionary parms) { if (string.IsNullOrEmpty(queryName)) @@ -92,7 +121,7 @@ public async Task Metric(QueryType queryType, string collection, string throw new ArgumentNullException("queryType"); if (string.IsNullOrWhiteSpace(collection)) throw new ArgumentNullException("collection"); - if (string.IsNullOrWhiteSpace(targetProperty) && (queryType!=QueryType.Count())) + if (string.IsNullOrWhiteSpace(targetProperty) && (queryType != QueryType.Count())) throw new ArgumentNullException("targetProperty"); var parms = new Dictionary(); @@ -246,24 +275,23 @@ public async Task> Extract(string collection, QueryTimeframe timeframe = null, IEnumerable filters = null, int latest = 0, string email = "") { var parms = new Dictionary(); - parms.Add(KeenConstants.QueryParmEventCollection, collection); - parms.Add(KeenConstants.QueryParmTimeframe, timeframe.ToSafeString()); - parms.Add(KeenConstants.QueryParmFilters, filters == null ? "" : JArray.FromObject(filters).ToString()); - parms.Add(KeenConstants.QueryParmEmail, email); - parms.Add(KeenConstants.QueryParmLatest, latest > 0 ? latest.ToString() : ""); + parms.Add(KeenConstants.QueryParmEventCollection, collection); + parms.Add(KeenConstants.QueryParmTimeframe, timeframe.ToSafeString()); + parms.Add(KeenConstants.QueryParmFilters, filters == null ? "" : JArray.FromObject(filters).ToString()); + parms.Add(KeenConstants.QueryParmEmail, email); + parms.Add(KeenConstants.QueryParmLatest, latest > 0 ? latest.ToString() : ""); var reply = await KeenWebApiRequest(KeenConstants.QueryExtraction, parms).ConfigureAwait(false); return from i in reply.Value("result") select (dynamic)i; } - - public async Task Funnel(IEnumerable steps, QueryTimeframe timeframe = null, string timezone = "") { @@ -280,9 +308,7 @@ public async Task Funnel(IEnumerable steps, return o; } - - - public async Task> MultiAnalysis(string collection, IEnumerable analysisParams, QueryTimeframe timeframe = null, IEnumerable filters = null, string timezone = "") + public async Task> MultiAnalysis(string collection, IEnumerable analysisParams, QueryTimeframe timeframe = null, IEnumerable filters = null, string timezone = "") { var jObs = analysisParams.Select(x => new JProperty( x.Label, JObject.FromObject( new {analysis_type = x.Analysis, target_property = x.TargetProperty }))); @@ -320,7 +346,7 @@ public async Task>>> Mul var reply = await KeenWebApiRequest(KeenConstants.QueryMultiAnalysis, parms).ConfigureAwait(false); - var result = new List>>(); + var result = new List>>(); foreach (JObject i in reply.Value("result")) { var d = new Dictionary(); @@ -339,7 +365,7 @@ public async Task>>> Mul return result; } - public async Task>>> MultiAnalysis(string collection, IEnumerable analysisParams, QueryTimeframe timeframe = null, QueryInterval interval = null, IEnumerable filters = null, string timezone = "") + public async Task>>> MultiAnalysis(string collection, IEnumerable analysisParams, QueryTimeframe timeframe = null, QueryInterval interval = null, IEnumerable filters = null, string timezone = "") { var jObs = analysisParams.Select(x => new JProperty(x.Label, JObject.FromObject(new { analysis_type = x.Analysis, target_property = x.TargetProperty }))); var parmsJson = JsonConvert.SerializeObject(new JObject(jObs), Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); @@ -395,7 +421,6 @@ public async Task()) { - if (p.Name == groupby) grpVal = (string)p.Value; else diff --git a/Keen/Query/QueryAbsoluteTimeframe.cs b/Keen/Query/QueryAbsoluteTimeframe.cs index c3ccee2..cac1a25 100644 --- a/Keen/Query/QueryAbsoluteTimeframe.cs +++ b/Keen/Query/QueryAbsoluteTimeframe.cs @@ -1,9 +1,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; + namespace Keen.Core.Query { diff --git a/Keen/Query/QueryFilter.cs b/Keen/Query/QueryFilter.cs index e622d8f..5e77125 100644 --- a/Keen/Query/QueryFilter.cs +++ b/Keen/Query/QueryFilter.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using System; + namespace Keen.Core.Query { /// @@ -40,7 +41,6 @@ public sealed class FilterOperator /// public static FilterOperator Equals() { return new FilterOperator("eq"); } - /// /// Not equal to. /// Use with string, number @@ -91,12 +91,17 @@ public sealed class FilterOperator /// public static FilterOperator Contains() { return new FilterOperator("contains"); } + /// + /// Filter on events that do not contain the specified property value. + /// Use with strings + /// + public static FilterOperator NotContains() { return new FilterOperator("not_contains"); } + /// /// Used to select events within a certain radius of the provided geo coordinate. /// Use with geo analysis /// public static FilterOperator Within() { return new FilterOperator("within"); } - } @@ -135,15 +140,20 @@ public GeoValue(double longitude, double latitude, double maxDistanceMiles) public QueryFilter() { - + } public QueryFilter(string property, FilterOperator op, object value) { if (string.IsNullOrWhiteSpace(property)) - throw new ArgumentNullException("property"); - if (null == value) - throw new ArgumentNullException("value"); + { + throw new ArgumentNullException(nameof(property), "Property name is required."); + } + + if (null == op) + { + throw new ArgumentNullException(nameof(op), "Filter operator is required."); + } PropertyName = property; Operator = op; diff --git a/Keen/Query/QueryGroupValue.cs b/Keen/Query/QueryGroupValue.cs index 6cb2bd3..6c5d906 100644 --- a/Keen/Query/QueryGroupValue.cs +++ b/Keen/Query/QueryGroupValue.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core.Query { /// diff --git a/Keen/Query/QueryInterval.cs b/Keen/Query/QueryInterval.cs index 089ec52..e6f21c3 100644 --- a/Keen/Query/QueryInterval.cs +++ b/Keen/Query/QueryInterval.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core.Query { /// diff --git a/Keen/Query/QueryIntervalValue.cs b/Keen/Query/QueryIntervalValue.cs index 8aa1d67..035b790 100644 --- a/Keen/Query/QueryIntervalValue.cs +++ b/Keen/Query/QueryIntervalValue.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; + namespace Keen.Core.Query { diff --git a/Keen/Query/QueryRelativeTimeframe.cs b/Keen/Query/QueryRelativeTimeframe.cs index 4e85f1a..6bf31f4 100644 --- a/Keen/Query/QueryRelativeTimeframe.cs +++ b/Keen/Query/QueryRelativeTimeframe.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core.Query { /// diff --git a/Keen/Query/QueryTimeframe.cs b/Keen/Query/QueryTimeframe.cs index a32fa6c..594072c 100644 --- a/Keen/Query/QueryTimeframe.cs +++ b/Keen/Query/QueryTimeframe.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core.Query { public class QueryTimeframe diff --git a/Keen/Query/QueryType.cs b/Keen/Query/QueryType.cs index 55b91eb..f9c1079 100644 --- a/Keen/Query/QueryType.cs +++ b/Keen/Query/QueryType.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - + namespace Keen.Core.Query { public sealed class QueryType diff --git a/README.md b/README.md index 1491043..0b26aed 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,6 @@ Installation The easiest way to get started with the Keen IO .NET SDK is to use the [KeenClient NuGet package](http://www.nuget.org/packages/KeenClient/). -Note that there is currently an issue and workaround for installation of the library for .NET 3.5 targets using NuGet: [https://github.com/keenlabs/keen-sdk-net/issues/38](https://github.com/keenlabs/keen-sdk-net/issues/38). - Install the NuGet package by running the following command from the NuGet Package Manager Console: ``` diff --git a/SharedVersionInfo.cs b/SharedVersionInfo.cs index ccb7bfc..f8d3a01 100644 --- a/SharedVersionInfo.cs +++ b/SharedVersionInfo.cs @@ -13,14 +13,14 @@ // AssemblyVersion can only contain numerical values, so no pre-release or metadata info like // "-alpha123" can go in here. -[assembly: AssemblyVersion("0.3.16")] +[assembly: AssemblyVersion("0.3.17")] // AssemblyInformationalVersion can have more information in non-numerical format. Here is // where we could/should put pre-release and/or metadata info if we want to release a version // as "1.2.3-beta" or similar. -[assembly: AssemblyInformationalVersion("0.3.16")] +[assembly: AssemblyInformationalVersion("0.3.17")] // AssemblyFileVersion can and should differ in each assembly if we get into a situation where // a given assembly needs to be rebuilt and we'd like to track that separately, but we don't // intend to bump the SDK version nor the NuGet version. Leave it here until then. -[assembly: AssemblyFileVersion("0.3.16")] +[assembly: AssemblyFileVersion("0.3.17")]