From 59823f5cded1da980bc6158c169fafba03423ee9 Mon Sep 17 00:00:00 2001 From: Benjamin Papillon Date: Sun, 30 Jun 2024 23:19:31 -0400 Subject: [PATCH] V1 custom code (#13) * V1 custom code * Unit tests for custom code, fixed bugs, custom event buffer implemenation * add test CI action * ensure cache unique key on null values - update test * update readme --------- Co-authored-by: Nasim Saleh --- .fernignore | 10 + .github/workflows/ci.yml | 16 + README.md | 307 ++++++++++++++++-- .../SchematicHQ.Client.Test.csproj | 16 +- src/SchematicHQ.Client.Test/TestCache.cs | 174 ++++++++++ src/SchematicHQ.Client.Test/TestClient.cs | 251 +++++++++++++- .../TestEventBuffer.cs | 239 ++++++++++++++ src/SchematicHQ.Client.Test/TestLogger.cs | 152 +++++++++ src/SchematicHQ.Client/Cache.cs | 111 +++++++ .../Core/ClientOptionsCustom.cs | 35 ++ src/SchematicHQ.Client/EventBuffer.cs | 234 +++++++++++++ src/SchematicHQ.Client/Logger.cs | 34 ++ src/SchematicHQ.Client/Schematic.cs | 173 ++++++++++ 13 files changed, 1710 insertions(+), 42 deletions(-) create mode 100644 src/SchematicHQ.Client.Test/TestCache.cs create mode 100644 src/SchematicHQ.Client.Test/TestEventBuffer.cs create mode 100644 src/SchematicHQ.Client.Test/TestLogger.cs create mode 100644 src/SchematicHQ.Client/Cache.cs create mode 100644 src/SchematicHQ.Client/Core/ClientOptionsCustom.cs create mode 100644 src/SchematicHQ.Client/EventBuffer.cs create mode 100644 src/SchematicHQ.Client/Logger.cs create mode 100644 src/SchematicHQ.Client/Schematic.cs diff --git a/.fernignore b/.fernignore index 118c630..74ff706 100644 --- a/.fernignore +++ b/.fernignore @@ -1,2 +1,12 @@ # Specify files that shouldn't be modified by Fern README.md + +src/SchematicHQ.Client.Test/TestCache.cs +src/SchematicHQ.Client.Test/TestEventBuffer.cs +src/SchematicHQ.Client.Test/TestClient.cs +src/SchematicHQ.Client.Test/TestLogger.cs +src/SchematicHQ.Client/Cache.cs +src/SchematicHQ.Client/Core/ClientOptionsCustom.cs +src/SchematicHQ.Client/EventBuffer.cs +src/SchematicHQ.Client/Logger.cs +src/SchematicHQ.Client/Schematic.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac82e8c..23c152b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,22 @@ jobs: - name: Build Release run: dotnet build src -c Release /p:ContinuousIntegrationBuild=true + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Build Release + run: dotnet test src publish: needs: [compile] diff --git a/README.md b/README.md index ad1741f..7635676 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,24 @@ # Schematic .NET Library -[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-SDK%20generated%20by%20Fern-brightgreen)](https://github.com/fern-api/fern) - The official Schematic C# library, supporting .NET Standard, .NET Core, and .NET Framework. -## Installation +## Installation and Setup -Using the .NET Core command-line interface (CLI) tools: +1. Install the library using the .NET Core command-line interface (CLI) tools: ```sh dotnet add package SchematicHQ.Client ``` -Using the NuGet Command Line Interface (CLI): +or using the NuGet Command Line Interface (CLI): ```sh nuget install SchematicHQ.Client ``` -## Instantiation -Instantiate the SDK using the `Schematic` client class. Note that all -of the SDK methods are awaitable! +2. [Issue an API key](https://docs.schematichq.com/quickstart#create-an-api-key) for the appropriate environment using the [Schematic app](https://app.schematichq.com/settings/api-keys). + +3. Using this secret key, initialize a client in your application: ```csharp using SchematicHQ; @@ -28,47 +26,275 @@ using SchematicHQ; Schematic schematic = new Schematic("YOUR_API_KEY") ``` -## HTTP Client -You can override the HttpClient by passing in `ClientOptions`. +## Usage + +### Sending identify events + +Create or update users and companies using identify events. ```csharp -schematic = new Schematic("YOUR_API_KEY", new ClientOptions{ - HttpClient = ... // Override the Http Client - BaseURL = ... // Override the Base URL -}) +using SchematicHQ.Client; +using System.Collections.Generic; +using OneOf; + +Schematic schematic = new Schematic("YOUR_API_KEY"); + +schematic.Identify( + keys: new Dictionary + { + { "email", "wcoyote@acme.net" }, + { "user_id", "your-user-id" } + }, + company: new EventBodyIdentifyCompany + { + Keys = new Dictionary { { "id", "your-company-id" } }, + Name = "Acme Widgets, Inc.", + Traits = new Dictionary>> + { + { "city", "Atlanta" } + } + }, + name: "Wile E. Coyote", + traits: new Dictionary>> + { + { "login_count", 24 }, + { "is_staff", false } + } +); ``` -## Exception Handling -When the API returns a non-zero status code, (4xx or 5xx response), -a subclass of SchematicException will be thrown: +This call is non-blocking and there is no response to check. + +### Sending track events + +Track activity in your application using track events; these events can later be used to produce metrics for targeting. ```csharp -using SchematicHQ; +Schematic schematic = new Schematic("YOUR_API_KEY"); +schematic.Track( + eventName: "some-action", + user: new Dictionary { { "user_id", "your-user-id" } }, + company: new Dictionary { { "id", "your-company-id" } } +); +``` -try { - schematic.Accounts.ListApiKeysAsync(...); -} catch (SchematicException e) { - System.Console.WriteLine(e.Message) - System.Console.WriteLine(e.StatusCode) +This call is non-blocking and there is no response to check. + +### Creating and updating companies + +Although it is faster to create companies and users via identify events, if you need to handle a response, you can use the companies API to upsert companies. Because you use your own identifiers to identify companies, rather than a Schematic company ID, creating and updating companies are both done via the same upsert operation: + + +```csharp +using SchematicHQ.Client; +using System.Collections.Generic; +using System.Threading.Tasks; + +Schematic schematic = new Schematic("YOUR_API_KEY"); + +// Creating and updating companies +async Task UpsertCompanyExample() +{ + var response = await schematic.API.Companies.UpsertCompanyAsync(new UpsertCompanyRequestBody + { + Keys = new Dictionary { { "id", "your-company-id" } }, + Name = "Acme Widgets, Inc.", + Traits = new Dictionary + { + { "city", "Atlanta" }, + { "high_score", 25 }, + { "is_active", true } + } + }); + + // Handle the response as needed + Console.WriteLine($"Company upserted: {response.Data.Name}"); } ``` -## Usage +You can define any number of company keys; these are used to address the company in the future, for example by updating the company's traits or checking a flag for the company. + +You can also define any number of company traits; these can then be used as targeting parameters. -Below are code snippets of how you can use the C# SDK. +### Creating and updating users + +Similarly, you can upsert users using the Schematic API, as an alternative to using identify events. Because you use your own identifiers to identify users, rather than a Schematic user ID, creating and updating users are both done via the same upsert operation: ```csharp -using SchematicHQ; +using SchematicHQ.Client; +using System.Collections.Generic; +using System.Threading.Tasks; -Schematic schematic = new Schematic("YOUR_API_KEY") -Employee employee = schematic.Accounts.ListApiKeysAsync( - new ListApiKeysRequest{ - RequireEnvironment = true - } +Schematic schematic = new Schematic("YOUR_API_KEY"); + +// Creating and updating users +async Task UpsertUserExample() +{ + var response = await schematic.API.Companies.UpsertUserAsync(new UpsertUserRequestBody + { + Keys = new Dictionary + { + { "email", "wcoyote@acme.net" }, + { "user_id", "your-user-id" } + }, + Name = "Wile E. Coyote", + Traits = new Dictionary + { + { "city", "Atlanta" }, + { "high_score", 25 }, + { "is_active", true } + }, + Company = new Dictionary { { "id", "your-company-id" } } + }); + + // Handle the response as needed + Console.WriteLine($"User upserted: {response.Data.Name}"); +} +``` + +You can define any number of user keys; these are used to address the user in the future, for example by updating the user's traits or checking a flag for the user. + +You can also define any number of user traits; these can then be used as targeting parameters. + +### Checking flags + +When checking a flag, you'll provide keys for a company and/or keys for a user. You can also provide no keys at all, in which case you'll get the default value for the flag. + +```csharp +Schematic schematic = new Schematic("YOUR_API_KEY"); + +bool flagValue = await schematic.CheckFlag( + "some-flag-key", + company: new Dictionary { { "id", "your-company-id" } }, + user: new Dictionary { { "user_id", "your-user-id" } } ); ``` -## Retries +## Configuration Options + +There are a number of configuration options that can be specified passing in `ClientOptions` as a second parameter when instantiating the Schematic client. + +### Flag Check Options + +By default, the client will do some local caching for flag checks. If you would like to change this behavior, you can do so using an initialization option to specify the max size of the cache (in terms of number of cached items) and the max age of the cache: + +```csharp +using SchematicHQ.Client; +using System.Collections.Generic; + +int cacheMaxItems = 1000; // Max number of entries in the cache +TimeSpan cacheTtl = TimeSpan.FromSeconds(1); // Set TTL to 1 second + +var options = new ClientOptions +{ + CacheProviders = new List> + { + new LocalCache(cacheMaxItems, cacheTtl) + } +}; + +Schematic schematic = new Schematic("YOUR_API_KEY", options); +``` +***Note about LocalCache:*** LocalCache implementation returns default value the type it is initiated with when it is a cache miss. Hence, when using with Schematic it is initiated with type (bool?) so that cache returns null when it is a miss + +You can also disable local caching entirely; bear in mind that, in this case, every flag check will result in a network request: + +```csharp +using SchematicHQ.Client; +using System.Collections.Generic; + +var options = new ClientOptions +{ + CacheProviders = new List>() +}; + +Schematic schematic = new Schematic("YOUR_API_KEY", options); +``` + +You may want to specify default flag values for your application, which will be used if there is a service interruption or if the client is running in offline mode (see below): + +```csharp +using SchematicHQ.Client; +using System.Collections.Generic; + +var options = new ClientOptions +{ + FlagDefaults = new Dictionary + { + { "some-flag-key", true } + } +}; + +Schematic schematic = new Schematic("YOUR_API_KEY", options); +``` + +### Offline Mode + +In development or testing environments, you may want to avoid making network requests to the Schematic API. You can run Schematic in offline mode by specifying the `Offline` option; in this case, it does not matter what API key you specify: + +```csharp +using SchematicHQ.Client; + +var options = new ClientOptions +{ + Offline = true +}; + +Schematic schematic = new Schematic("", options); // API key doesn't matter in offline mode +``` + +Offline mode works well with flag defaults: + +```csharp +using SchematicHQ.Client; +using System.Collections.Generic; + +var options = new ClientOptions +{ + FlagDefaults = new Dictionary + { + { "some-flag-key", true } + }, + Offline = true +}; + +Schematic schematic = new Schematic("", options); + +bool flagValue = await schematic.CheckFlag("some-flag-key"); // Returns true +``` + +### Event Buffer +Schematic API uses an Event Buffer to batch *Identify* and *Track* requests and avoid multiple API calls. +You can set the event buffer flush period in options: +```csharp +using SchematicHQ.Client; + +var options = new ClientOptions +{ + DefaultEventBufferPeriod = TimeSpan.FromSeconds(5) +}; +``` +You may also want to use your custom event buffer. To do so, your custom event buffer has to implement *IEventBuffer* interface, and pass an instance to the Schematic API through options: +```csharp +using SchematicHQ.Client; + +var options = new ClientOptions +{ + EventBuffer = new MyCustomEventBuffer();//instance of your custom event buffer +} +``` + +### HTTP Client +You can override the HttpClient: + +```csharp +schematic = new Schematic("YOUR_API_KEY", new ClientOptions{ + HttpClient = ... // Override the Http Client + BaseURL = ... // Override the Base URL +}) +``` + +### Retries 429 Rate Limit, and >=500 Internal errors will all be retried twice with exponential backoff. You can override this behavior globally or per-request. @@ -79,7 +305,7 @@ var schematic = new Schematic("...", new ClientOptions{ }); ``` -## Timeouts +### Timeouts The SDK defaults to a 60s timeout. You can override this behaviour globally or per-request. @@ -89,6 +315,21 @@ var schematic = new Schematic("...", new ClientOptions{ }); ``` +## Exception Handling +When the API returns a non-zero status code, (4xx or 5xx response), +a subclass of SchematicException will be thrown: + +```csharp +using SchematicHQ; + +try { + schematic.Accounts.ListApiKeysAsync(...); +} catch (SchematicException e) { + System.Console.WriteLine(e.Message) + System.Console.WriteLine(e.StatusCode) +} +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. Additions made directly to this library diff --git a/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.csproj b/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.csproj index 4241ce0..bfe53df 100644 --- a/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.csproj +++ b/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable enable @@ -10,15 +10,17 @@ - - - - - + + + + + + + - \ No newline at end of file + diff --git a/src/SchematicHQ.Client.Test/TestCache.cs b/src/SchematicHQ.Client.Test/TestCache.cs new file mode 100644 index 0000000..7f0c48d --- /dev/null +++ b/src/SchematicHQ.Client.Test/TestCache.cs @@ -0,0 +1,174 @@ +using NUnit.Framework; +using System.Data.SqlTypes; + +#nullable enable + +namespace SchematicHQ.Client.Test +{ + [TestFixture] + public class TestCache + { + static IEnumerable SeTAndGetTestCases + { + get + { + yield return new TestCaseData(new LocalCache(), true).SetName("TestSetAndGet_Boolean"); + yield return new TestCaseData(new LocalCache(), "test_string").SetName("TestSetAndGet_string"); + yield return new TestCaseData(new LocalCache>(), new List { "test_string1", "test_string2" }).SetName("TestSetAndGet_RefType"); + } + } + + [Test, TestCaseSource(nameof(SeTAndGetTestCases))] + public void Get_ReturnsSetValue(ICacheProvider cache, T value) + { + string key = "test_key"; + + cache.Set(key: key, val: value); + var result = cache.Get(key); + + Assert.That(result, Is.EqualTo(value)); + } + + [Test] + public void Test_DefaultTTL() + { + LocalCache cacheProvider = new LocalCache(maxItems: 1); + bool? expectedResult = true; + var key = "test_key"; + + cacheProvider.Set(key: key, val: expectedResult); + var existingResult = cacheProvider.Get(key); + Thread.Sleep(LocalCache.DEFAULT_CACHE_TTL + TimeSpan.FromMilliseconds(5)); + var evictedResult = cacheProvider.Get(key); + + Assert.That(existingResult, Is.EqualTo(expectedResult)); + Assert.That(evictedResult, Is.Null); + } + + [Test] + public void Test_DefaultCapacity() + { + LocalCache cacheProvider = new LocalCache(ttl: TimeSpan.FromMinutes(10)); + int? expectedResult = -1; + var key = "test_key"; + + cacheProvider.Set(key: key, val: expectedResult); + foreach (int i in Enumerable.Range(1, LocalCache.DEFAULT_CACHE_CAPACITY - 1)) + { + cacheProvider.Set(key: i.ToString(), val: i); + + Assert.That(cacheProvider.Get(key: key), Is.EqualTo(expectedResult)); + } + cacheProvider.Set(key: "new_key", val: -2); + var evictedResult = cacheProvider.Get(1.ToString()); + + Assert.That(cacheProvider.Get(key: key), Is.EqualTo(expectedResult)); + Assert.That(cacheProvider.Get(key: "new_key"), Is.EqualTo(-2)); + Assert.That(evictedResult, Is.Null); + } + + [Test] + public void Test_NotExistentKeyReturnsDefaultValue() + { + LocalCache cacheProvider = new LocalCache(maxItems: 1); + Assert.That(cacheProvider.Get("non_existent_key"), Is.Null); + } + + [Test] + public void Test_ConcurrentAccess() + { + int numberOfThreads = 10; + int cacheCapacity = 30; + LocalCache cacheProvider = new LocalCache(maxItems: cacheCapacity, ttl: TimeSpan.FromHours(5)); + var tasks = new List(); + var countdownEvent = new CountdownEvent(1); + + for (int t = 0; t < numberOfThreads; t++) + { + int start = t * cacheCapacity + 1; + int end = start + cacheCapacity - 1; + + tasks.Add(Task.Run(() => + { + countdownEvent.Wait(); + for (int i = start; i <= end; i++) + { + cacheProvider.Set(i.ToString(), i); + } + })); + } + + countdownEvent.Signal(); + Task.WaitAll(tasks.ToArray()); + + List cacheHitsIndices = new List(); + + for (int i = 1; i <= numberOfThreads * cacheCapacity; i++) + { + if (cacheProvider.Get(i.ToString()) == i) + { + cacheHitsIndices.Add(i); + } + } + + Assert.That(cacheHitsIndices.Count, Is.EqualTo(cacheCapacity)); + Assert.That( + cacheHitsIndices[cacheCapacity - 1] - cacheHitsIndices[0] + 1, + Is.Not.EqualTo(cacheCapacity) + ); + } + + [Test] + public void Test_TTLOverride() + { + LocalCache cacheProvider = new LocalCache(maxItems: 1000, ttl: TimeSpan.FromHours(5)); + var tasks = new List(); + var countdownEvent = new CountdownEvent(1); + string key = "test_key"; + int expectedValue = 5; + TimeSpan ttlOverride = TimeSpan.FromSeconds(3); + + tasks.Add(Task.Run(() => + { + countdownEvent.Wait(); + cacheProvider.Set(key: key, val: expectedValue, ttlOverride: ttlOverride); + })); + tasks.Add(Task.Run(() => + { + countdownEvent.Wait(); + Thread.Sleep(1000); + Assert.That(cacheProvider.Get(key), Is.EqualTo(expectedValue)); + })); + tasks.Add(Task.Run(() => + { + countdownEvent.Wait(); + Thread.Sleep(ttlOverride + TimeSpan.FromMilliseconds(1)); + Assert.That(cacheProvider.Get(key), Is.Null); + })); + + countdownEvent.Signal(); + Task.WaitAll(tasks.ToArray()); + } + + [Test] + public void Test_EvictionByLastAccessed() + { + LocalCache cacheProvider = new LocalCache(maxItems: 10, ttl: TimeSpan.FromHours(5)); + foreach (var i in Enumerable.Range(1, 10)) + { + cacheProvider.Set(i.ToString(), i); + } + + foreach (var i in Enumerable.Range(1, 10)) + { + Assert.That(cacheProvider.Get(i.ToString()), Is.EqualTo(i)); + } + + foreach (var I in Enumerable.Range(1, 10)) + { + cacheProvider.Set((I + 10).ToString(), -1); + Assert.That(cacheProvider.Get(I.ToString()), Is.Null); + } + } + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client.Test/TestClient.cs b/src/SchematicHQ.Client.Test/TestClient.cs index 22baeb9..2ca2fed 100644 --- a/src/SchematicHQ.Client.Test/TestClient.cs +++ b/src/SchematicHQ.Client.Test/TestClient.cs @@ -1,3 +1,250 @@ -namespace SchematicHQ.Client.Test; +using Moq; +using NUnit.Framework; +using System.Net; +using Moq.Contrib.HttpClient; +using System.Text.Json; +using System.Text; +using OneOf; -public class TestClient { } +namespace SchematicHQ.Client.Test +{ + [TestFixture] + public class SchematicTests + { + private Schematic _schematic; + private ClientOptions _options; + private Mock _logger; + private int _defaultEventBufferPeriod = 3; // seconds + + private HttpResponseMessage CreateCheckFlagResponse(HttpStatusCode code, bool flagValue) + { + var response = new CheckFlagResponse + { + Data = new CheckFlagResponseData + { + Value = flagValue + } + }; + var serializedResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + return new HttpResponseMessage(code) + { + Content = new StringContent(serializedResponse, Encoding.UTF8, "application/json") + }; + } + + private void SetupSchematicTestClient(bool isOffline, HttpResponseMessage response, Dictionary? flagDefaults = null) + { + HttpClient testClient = new OfflineHttpClient(); + _options = new ClientOptions + { + Logger = _logger.Object, + Offline = isOffline, + FlagDefaults = flagDefaults ?? new Dictionary(), + DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), + CacheProviders = new List> { new LocalCache() } + }; + + if (!_options.Offline) + { + var handler = new Mock(MockBehavior.Strict); + testClient = handler.CreateClient(); + + handler.SetupAnyRequest() + .ReturnsAsync(response); + } + _schematic = new Schematic("dummy_api_key", _options.WithHttpClient(testClient)); + } + + [SetUp] + public void Setup() + { + _logger = new Mock(); + } + + [TearDown] + public void TearDown() + { + if (_schematic != null) _schematic.Shutdown(); + } + + [Test] + public async Task CheckFlag_CachesResultIfNotCached() + { + SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, true)); + string flagKey = "test_flag"; + + // Act + var result = await _schematic.CheckFlag(flagKey); + + // Assert + Assert.That(result, Is.True); + foreach (var cacheProvider in _options.CacheProviders) + { + Assert.That(cacheProvider.Get(flagKey), Is.EqualTo(true)); + } + } + + [Test] + public async Task CheckFlag_StoreCorrectCacheKey() + { + // Arrange + SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); + string flagKey = "test_flag"; + string cacheKey = "test_flag:c-name=test_company:u-id=unique_id"; + foreach (var cacheProvider in _options.CacheProviders) + { + cacheProvider.Set(cacheKey, true); + } + + // Act + var result = await _schematic.CheckFlag( + flagKey: flagKey, + company: new Dictionary { { "name", "test_company" } }, + user: new Dictionary { { "id", "unique_id" } } + ); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task CheckFlag_ReturnsCachedValueIfExists() + { + // Arrange + SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); + string flagKey = "test_flag"; + foreach (var cacheProvider in _options.CacheProviders) + { + cacheProvider.Set(flagKey, true); + } + + // Act + var result = await _schematic.CheckFlag(flagKey); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task CheckFlag_LogsErrorAndReturnsDefaultOnException() + { + // Arrange + string flagKey = "error_flag"; + SetupSchematicTestClient( + isOffline: false, + response: CreateCheckFlagResponse(HttpStatusCode.InternalServerError, false), + flagDefaults: new Dictionary { { flagKey, true } } + ); + + // Act + var result = await _schematic.CheckFlag(flagKey); + + // Assert + Assert.That(result, Is.True); // default is true for the test flag + _logger.Verify(logger => logger.Error("Error checking flag: {0}", It.IsAny()), Times.Once); + } + + [Test] + public async Task Identify_EnqueuesEventNonBlocking() + { + // Arrange + SetupSchematicTestClient(isOffline: false, response: null); + var keys = new Dictionary { { "user_id", "12345" } }; + var company = new EventBodyIdentifyCompany { Name = "test_company" }; + + // Act + var identifyTask = Task.Run(() => _schematic.Identify(keys, company, "John Doe", new Dictionary>>())); + + // Assert + await identifyTask; // Ensure the task completes + await Task.Delay(100); // Allow some time for the event to be processed asynchronously + Assert.That(_schematic.GetBufferWaitingEventCount(), Is.EqualTo(1)); + } + + [Test] + public async Task Track_EnqueuesEventNonBlocking() + { + // Arrange + SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); + var company = new Dictionary { { "company_id", "67890" } }; + var user = new Dictionary { { "user_id", "12345" } }; + + // Act + var trackTask = Task.Run(() => _schematic.Track("event_name", company, user, new Dictionary>>())); + + // Assert + await trackTask; // Ensure the task completes + await Task.Delay(100); // Allow some time for the event to be processed asynchronously + Assert.That(_schematic.GetBufferWaitingEventCount(), Is.EqualTo(1)); + } + + [Test] + public async Task EventBuffer_FlushesPeriodically() + { + // Arrange + SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); + + // Act + for (int i = 0; i < 10; i++) + { + _schematic.Track($"event_{i}"); + } + + // Assert + Assert.That(_schematic.GetBufferWaitingEventCount(), Is.EqualTo(10)); // Not Flushed yet + await Task.Delay((_defaultEventBufferPeriod * 1000) + 100); // Wait for the periodic flush to occur + Assert.That(_schematic.GetBufferWaitingEventCount(), Is.EqualTo(0)); // Assuming the buffer has been flushed + } + + [Test] + public void Track_OfflineMode() + { + // Arrange + SetupSchematicTestClient(isOffline: true, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); + + // Act + for (int i = 0; i < 10; i++) + { + _schematic.Track($"event_{i}"); + } + + // Assert + Assert.That(_schematic.GetBufferWaitingEventCount(), Is.EqualTo(0)); // nothing should be added to buffer in offline mode + } + + [Test] + public void Identify_OfflineMode() + { + // Arrange + SetupSchematicTestClient(isOffline: true, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); + var keys = new Dictionary { { "user_id", "12345" } }; + var company = new EventBodyIdentifyCompany { Name = "test_company" }; + + // Act + for (int i = 0; i < 10; i++) + { + _schematic.Identify(keys, company, "John Doe", new Dictionary>>()); + } + + // Assert + Assert.That(_schematic.GetBufferWaitingEventCount(), Is.EqualTo(0)); // nothing should be added to buffer in offline mode + } + + [Test] + public async Task CheckFlag_OfflineModeReturnsDefault() + { + // Arrange + SetupSchematicTestClient( + isOffline: true, + response: CreateCheckFlagResponse(HttpStatusCode.OK, false), + flagDefaults: new Dictionary { { "test_flag_key", true } } + ); + + // Act + var result = await _schematic.CheckFlag("test_flag_key"); + + // Assert + Assert.That(result, Is.True); + } + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client.Test/TestEventBuffer.cs b/src/SchematicHQ.Client.Test/TestEventBuffer.cs new file mode 100644 index 0000000..08ca4b0 --- /dev/null +++ b/src/SchematicHQ.Client.Test/TestEventBuffer.cs @@ -0,0 +1,239 @@ +using Moq; +using NUnit.Framework; + +namespace SchematicHQ.Client.Tests +{ + [TestFixture] + public class EventBufferTests + { + private Mock _mockLogger; + private EventBuffer _buffer; + private List _processedItems; + + [SetUp] + public void SetUp() + { + _mockLogger = new Mock(); + _processedItems = new List(); + _buffer = new EventBuffer(async items => + { + lock (_processedItems) + { + _processedItems.AddRange(items); + } + await Task.CompletedTask; // Simulate async operation + }, _mockLogger.Object); + } + + [TearDown] + public void TearDown() + { + _buffer.Dispose(); + } + + [Test] + public void Start_BufferStartsSuccessfully() + { + _buffer.Start(); + Assert.DoesNotThrow(() => _buffer.Push(1)); + } + + [Test] + public void Stop_BufferStopsSuccessfully() + { + _buffer.Start(); + _buffer.Stop(); + Assert.Throws(() => _buffer.Push(1)); + } + + [Test] + public async Task Push_ItemsAreAddedToBuffer() + { + _buffer.Start(); + _buffer.Push(1); + await _buffer.Flush(); + + Assert.That(_processedItems.Count, Is.EqualTo(1)); + Assert.That(_processedItems[0], Is.EqualTo(1)); + } + + [Test] + public async Task Flush_ManualFlushWorks() + { + _buffer.Start(); + _buffer.Push(1); + await _buffer.Flush(); + + Assert.That(_processedItems.Count, Is.EqualTo(1)); + Assert.That(_processedItems[0], Is.EqualTo(1)); + } + + [Test] + public void Dispose_BufferDisposesSuccessfully() + { + // Arrange + _buffer.Start(); + + // Act + Assert.DoesNotThrow(() => _buffer.Dispose()); + + // Assert + var semaphore = GetPrivateFieldValue(_buffer, "_semaphore"); + var cts = GetPrivateFieldValue(_buffer, "_cts"); + + Assert.That(IsSemaphoreSlimDisposed(semaphore), Is.True, "SemaphoreSlim was not disposed."); + Assert.That(IsCancellationTokenSourceDisposed(cts), Is.True, "CancellationTokenSource was not disposed."); + } + + [Test] + public void PeriodicFlush_FlushesAtInterval() + { + var autoResetEvent = new AutoResetEvent(false); + _buffer = new EventBuffer(async items => + { + _processedItems.AddRange(items); + autoResetEvent.Set(); + await Task.CompletedTask; // Simulate async operation + }, _mockLogger.Object, 100, TimeSpan.FromMilliseconds(500)); + + _buffer.Start(); + _buffer.Push(1); + + Assert.That(autoResetEvent.WaitOne(2000), Is.True); + Assert.That(_processedItems.Count, Is.EqualTo(1)); + Assert.That(_processedItems[0], Is.EqualTo(1)); + } + + [Test] + public async Task Push_ParallelOperations() + { + _buffer.Start(); + var tasks = Enumerable.Range(0, 1000).Select(i => Task.Run(() => _buffer.Push(i))).ToArray(); + + await Task.WhenAll(tasks); + await _buffer.Flush(); + + Assert.That(_processedItems.Count, Is.EqualTo(1000)); + } + + [Test] + public async Task Flush_DoesNotInterfereWithPeriodicFlush() + { + var flushes = 0; + var manualFlushes = 0; + + _buffer = new EventBuffer(async items => + { + flushes++; + if (items.Contains(-1)) + { + manualFlushes++; + } + await Task.CompletedTask; + }, _mockLogger.Object, 100, TimeSpan.FromMilliseconds(500)); + + _buffer.Start(); + + var pushTasks = Enumerable.Range(0, 1000).Select(i => Task.Run(() => _buffer.Push(i))).ToArray(); + await Task.WhenAll(pushTasks); + + // Manual flush + _buffer.Push(-1); + await _buffer.Flush(); + + // Wait for at least one periodic flush + Thread.Sleep(2000); + + _buffer.Stop(); + + Assert.That(flushes, Is.GreaterThan(1)); + Assert.That(manualFlushes, Is.EqualTo(1)); + } + + [Test] + public async Task StartAndStop_ConcurrencyTest() + { + var startStopTasks = Enumerable.Range(0, 100).Select(async i => + { + _buffer.Start(); + await Task.Delay(10); + _buffer.Stop(); + }).ToArray(); + + await Task.WhenAll(startStopTasks); + + // Ensure buffer is stopped at the end + Assert.Throws(() => _buffer.Push(1)); + } + + [Test] + public async Task PushAndFlush_ConcurrencyTest() + { + _buffer.Start(); + + var pushTasks = Enumerable.Range(0, 1000).Select(i => Task.Run(() => _buffer.Push(i))).ToArray(); + var flushTasks = Enumerable.Range(0, 100).Select(i => Task.Run(async () => + { + await Task.Delay(10); + await _buffer.Flush(); + })).ToArray(); + + await Task.WhenAll(pushTasks.Concat(flushTasks)); + + // Ensure all items were processed + await _buffer.Flush(); + Assert.That(_processedItems.Count, Is.EqualTo(1000)); + } + + [Test] + public async Task SimultaneousFlushes_DoNotInterfere() + { + _buffer.Start(); + + var pushTasks = Enumerable.Range(0, 1000).Select(i => Task.Run(() => _buffer.Push(i))).ToArray(); + + await Task.WhenAll(pushTasks); + + var flushTasks = Enumerable.Range(0, 10).Select(i => Task.Run(async () => + { + await _buffer.Flush(); + })).ToArray(); + + await Task.WhenAll(flushTasks); + + Assert.That(_processedItems.Count, Is.EqualTo(1000)); + } + + private bool IsSemaphoreSlimDisposed(SemaphoreSlim semaphore) + { + try + { + semaphore.Wait(0); // Attempt a wait operation + return false; // If no exception, it's not disposed + } + catch (ObjectDisposedException) + { + return true; // If ObjectDisposedException, it's disposed + } + } + + private bool IsCancellationTokenSourceDisposed(CancellationTokenSource cts) + { + try + { + cts.Cancel(); // Attempt to cancel + return false; // If no exception, it's not disposed + } + catch (ObjectDisposedException) + { + return true; // If ObjectDisposedException, it's disposed + } + } + + private T GetPrivateFieldValue(object obj, string fieldName) + { + var field = obj.GetType().GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (T)field?.GetValue(obj); + } + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client.Test/TestLogger.cs b/src/SchematicHQ.Client.Test/TestLogger.cs new file mode 100644 index 0000000..c9a6ad9 --- /dev/null +++ b/src/SchematicHQ.Client.Test/TestLogger.cs @@ -0,0 +1,152 @@ +using NUnit.Framework; + +namespace SchematicHQ.Client.Tests +{ + [TestFixture] + public class LoggerTests + { + private StringWriter _stringWriter; + private ConsoleLogger _logger; + + [SetUp] + public void SetUp() + { + //redirect console output + _stringWriter = new StringWriter(); + Console.SetOut(_stringWriter); + _logger = new ConsoleLogger(); + } + + [TearDown] + public void TearDown() + { + _stringWriter.Dispose(); + // reset console output writer + Console.SetOut(Console.Out); + } + + [Test] + public void Error_ShouldLogErrorMessage() + { + // Arrange + var message = "This is an error message"; + + // Act + _logger.Error(message); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[ERROR]"), Is.True); + Assert.That(output.Contains(message), Is.True); + } + + [Test] + public void Test_Warn_ShouldLogWarnMessage() + { + // Arrange + var message = "This is a warning message"; + + // Act + _logger.Warn(message); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[WARN]"), Is.True); + Assert.That(output.Contains(message), Is.True); + } + + [Test] + public void Test_Info_ShouldLogInfoMessage() + { + // Arrange + var message = "This is an info message"; + + // Act + _logger.Info(message); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[INFO]"), Is.True); + Assert.That(output.Contains(message), Is.True); + } + + [Test] + public void Test_Debug_ShouldLogDebugMessage() + { + // Arrange + var message = "This is a debug message"; + + // Act + _logger.Debug(message); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[DEBUG]"), Is.True); + Assert.That(output.Contains(message), Is.True); + } + + [Test] + public void Test_Error_ShouldFormatMessageWithArgs() + { + // Arrange + var message = "Error {0}"; + var arg = "123"; + + // Act + _logger.Error(message, arg); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[ERROR]"), Is.True); + Assert.That(output.Contains("Error 123"), Is.True); + } + + [Test] + public void Test_Warn_ShouldFormatMessageWithArgs() + { + // Arrange + var message = "Warning {0}"; + var arg = "123"; + + // Act + _logger.Warn(message, arg); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[WARN]"), Is.True); + Assert.That(output.Contains("Warning 123"), Is.True); + } + + [Test] + public void Test_Info_ShouldFormatMessageWithArgs() + { + // Arrange + var message = "Info {0}"; + var arg = "123"; + + // Act + _logger.Info(message, arg); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[INFO]"), Is.True); + Assert.That(output.Contains("Info 123"), Is.True); + } + + [Test] + public void Test_Debug_ShouldFormatMessageWithArgs() + { + // Arrange + var message = "Debug {0}"; + var arg = "123"; + + // Act + _logger.Debug(message, arg); + + // Assert + var output = _stringWriter.ToString().Trim(); + Assert.That(output.Contains("[DEBUG]"), Is.True); + Assert.That(output.Contains("Debug 123"), Is.True); + } + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Cache.cs b/src/SchematicHQ.Client/Cache.cs new file mode 100644 index 0000000..4a1c345 --- /dev/null +++ b/src/SchematicHQ.Client/Cache.cs @@ -0,0 +1,111 @@ +using System.Collections.Concurrent; + +#nullable enable + +namespace SchematicHQ.Client; + + public interface ICacheProvider + { + T? Get(string key); + void Set(string key, T val, TimeSpan? ttlOverride = null); + } + + public class LocalCache : ICacheProvider + { + public const int DEFAULT_CACHE_CAPACITY = 1000; + public static readonly TimeSpan DEFAULT_CACHE_TTL = TimeSpan.FromMilliseconds(5000); // 5000 milliseconds + + private readonly ConcurrentDictionary> _cache; + private readonly LinkedList _lruList; + private readonly object _lock = new object(); + private readonly int _maxItems; + private readonly TimeSpan _ttl; + + public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null) + { + _cache = new ConcurrentDictionary>(); + _lruList = new LinkedList(); + _maxItems = maxItems; + _ttl = ttl ?? DEFAULT_CACHE_TTL; + } + + public T? Get(string key) + { + if (_maxItems == 0) + return default; + + if (!_cache.TryGetValue(key, out var item)) + return default; + + if (DateTime.UtcNow > item.Expiration) + { + Remove(key); + return default; + } + + lock (_lock) + { + _lruList.Remove(item.Node); + _lruList.AddFirst(item.Node); + } + + return item.Value; + } + + public void Set(string key, T val, TimeSpan? ttlOverride = null) + { + if (_maxItems == 0) + return; + + var ttl = ttlOverride ?? _ttl; + var expiration = DateTime.UtcNow.Add(ttl); + + lock (_lock) + { + if (_cache.TryGetValue(key, out var existingItem)) + { + existingItem.Value = val; + existingItem.Expiration = expiration; + _lruList.Remove(existingItem.Node); + _lruList.AddFirst(existingItem.Node); + } + else + { + if (_cache.Count >= _maxItems) + { + var lruKey = _lruList.Last!.Value; + Remove(lruKey); + } + + var node = _lruList.AddFirst(key); + var newItem = new CachedItem(val, expiration, node); + _cache[key] = newItem; + } + } + } + + private void Remove(string key) + { + if (_cache.TryRemove(key, out var removedItem)) + { + lock (_lock) + { + _lruList.Remove(removedItem.Node); + } + } + } + } + + public class CachedItem + { + public T Value { get; set; } + public DateTime Expiration { get; set; } + public LinkedListNode Node { get; } + + public CachedItem(T value, DateTime expiration, LinkedListNode node) + { + Value = value; + Expiration = expiration; + Node = node; + } + } diff --git a/src/SchematicHQ.Client/Core/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/ClientOptionsCustom.cs new file mode 100644 index 0000000..a2446a0 --- /dev/null +++ b/src/SchematicHQ.Client/Core/ClientOptionsCustom.cs @@ -0,0 +1,35 @@ +using SchematicHQ.Client.Core; + +#nullable enable + +namespace SchematicHQ.Client; + +public partial class ClientOptions +{ + public Dictionary FlagDefaults { get; set; } + public ISchematicLogger Logger { get; set; } + public List> CacheProviders { get; set; } + public bool Offline { get; set; } + public TimeSpan? DefaultEventBufferPeriod { get; set; } + public IEventBuffer? EventBuffer { get; set; } +} + +public static class ClientOptionsExtensions +{ + public static ClientOptions WithHttpClient(this ClientOptions options, HttpClient httpClient) + { + return new ClientOptions + { + BaseUrl = options.BaseUrl, + HttpClient = httpClient, + MaxRetries = options.MaxRetries, + TimeoutInSeconds = options.TimeoutInSeconds, + FlagDefaults = options.FlagDefaults, + Logger = options.Logger, + CacheProviders = options.CacheProviders, + Offline = options.Offline, + DefaultEventBufferPeriod = options.DefaultEventBufferPeriod, + EventBuffer = options.EventBuffer + }; + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/EventBuffer.cs b/src/SchematicHQ.Client/EventBuffer.cs new file mode 100644 index 0000000..0d9a076 --- /dev/null +++ b/src/SchematicHQ.Client/EventBuffer.cs @@ -0,0 +1,234 @@ +using System.Collections.Concurrent; + +#nullable enable + +namespace SchematicHQ.Client; + +public interface IEventBuffer : IDisposable +{ + void Push(T item); + void Start(); + void Stop(); + Task Flush(); + int GetEventCount(); +} + +public class EventBuffer : IEventBuffer +{ + private const int DefaultMaxSize = 100; + private static readonly TimeSpan DefaultFlushPeriod = TimeSpan.FromMilliseconds(5000); + private const int MaxWaitForBuffer = 3; //seconds to wait for event buffer to flush on Stop and Shutdown + + private readonly int _maxSize; + private readonly TimeSpan _flushPeriod; + private readonly Func, Task> _action; + private readonly ISchematicLogger _logger; + private readonly ConcurrentQueue _queue; + private readonly SemaphoreSlim _semaphore; + private CancellationTokenSource _cts; + private bool _isRunning; + private Task _periodicFlushTask = Task.CompletedTask; + private Task _processBufferTask = Task.CompletedTask; + private readonly object _taskLock = new object(); + private readonly object _runningLock = new object(); + + public EventBuffer(Func, Task> action, ISchematicLogger logger, int maxSize = DefaultMaxSize, TimeSpan? flushPeriod = null) + { + _maxSize = maxSize; + _flushPeriod = flushPeriod ?? DefaultFlushPeriod; + _action = action ?? throw new ArgumentNullException(nameof(action)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _queue = new ConcurrentQueue(); + _semaphore = new SemaphoreSlim(0); + _cts = new CancellationTokenSource(); + _isRunning = false; + + _logger.Info("EventBuffer initialized with maxSize: {0}, flushPeriod: {1}", _maxSize, _flushPeriod); + } + + public void Push(T item) + { + lock (_runningLock) + { + if (!_isRunning) throw new InvalidOperationException("Buffer is not running."); + } + + _queue.Enqueue(item); + _logger.Debug("Item added to buffer. Current size: {0}", _queue.Count); + if (_queue.Count >= _maxSize) + { + _semaphore.Release(); + } + } + + public void Start() + { + lock (_runningLock) + { + if (_isRunning) return; + + _isRunning = true; + _cts = new CancellationTokenSource(); + } + + lock (_taskLock) + { + if (_periodicFlushTask == Task.CompletedTask || _periodicFlushTask.IsCompleted) + { + _periodicFlushTask = Task.Run(() => PeriodicFlushAsync(_cts.Token)); + } + if (_processBufferTask == Task.CompletedTask || _processBufferTask.IsCompleted) + { + _processBufferTask = Task.Run(() => ProcessBufferAsync(_cts.Token)); + } + } + + _logger.Info("EventBuffer started."); + } + + public void Stop() + { + lock (_runningLock) + { + if (!_isRunning) return; + + _isRunning = false; + _cts.Cancel(); + } + + // Wait for a maximum of MaxWaitForBuffer seconds for the periodic flush task to complete + if (_periodicFlushTask != Task.CompletedTask) + { + try + { + _periodicFlushTask.Wait(TimeSpan.FromSeconds(MaxWaitForBuffer)); + } + catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is TaskCanceledException)) + { + _logger.Warn("Periodic flush task was canceled."); + } + } + + _logger.Info("EventBuffer stopped."); + } + + public async Task Flush() + { + lock (_runningLock) + { + if (!_isRunning) throw new InvalidOperationException("Buffer is not running."); + } + + await FlushBufferAsync(); + _logger.Info("Buffer flushed manually."); + } + + private async Task ProcessBufferAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await _semaphore.WaitAsync(token); + await FlushBufferAsync(); + } + catch (OperationCanceledException) + { + _logger.Warn("Process buffer task was canceled."); + } + } + } + + private async Task PeriodicFlushAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_flushPeriod, token); + + if (_queue.Count > 0) + { + await FlushBufferAsync(); + } + } + catch (OperationCanceledException) + { + _logger.Warn("Periodic flush task was canceled."); + } + catch (Exception ex) + { + _logger.Error("An error occurred during periodic flush: {0}", ex.Message); + } + } + } + + private async Task FlushBufferAsync() + { + var items = new List(); + while (items.Count < _maxSize && _queue.TryDequeue(out var item)) + { + items.Add(item); + } + + if (items.Count > 0) + { + _logger.Info("Flushing buffer with {0} items.", items.Count); + await _action(items); + } + } + + public void Dispose() + { + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // The CancellationTokenSource has already been disposed + } + + // Wait for a maximum of MaxWaitForBuffer seconds for the periodic flush task to complete + if (_periodicFlushTask != Task.CompletedTask) + { + try + { + _periodicFlushTask.Wait(TimeSpan.FromSeconds(MaxWaitForBuffer)); + } + catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is TaskCanceledException)) + { + _logger.Warn("Periodic flush task was canceled."); + } + catch (ObjectDisposedException) + { + _logger.Warn("Periodic flush task's CancellationTokenSource was disposed."); + } + } + + if (_processBufferTask != Task.CompletedTask) + { + try + { + _processBufferTask.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is TaskCanceledException)) + { + _logger.Warn("Process buffer task was canceled."); + } + catch (ObjectDisposedException) + { + _logger.Warn("Process buffer task's CancellationTokenSource was disposed."); + } + } + + _semaphore.Dispose(); + _cts.Dispose(); + _logger.Info("EventBuffer disposed."); + } + + public int GetEventCount() + { + return _queue.Count; + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Logger.cs b/src/SchematicHQ.Client/Logger.cs new file mode 100644 index 0000000..8f9e6fd --- /dev/null +++ b/src/SchematicHQ.Client/Logger.cs @@ -0,0 +1,34 @@ +#nullable enable + +namespace SchematicHQ.Client; + +public interface ISchematicLogger +{ + void Error(string message, params object[] args); + void Warn(string message, params object[] args); + void Info(string message, params object[] args); + void Debug(string message, params object[] args); +} + +public class ConsoleLogger : ISchematicLogger +{ + public void Error(string message, params object[] args) + { + Console.WriteLine($"[ERROR] {string.Format(message, args)}"); + } + + public void Warn(string message, params object[] args) + { + Console.WriteLine($"[WARN] {string.Format(message, args)}"); + } + + public void Info(string message, params object[] args) + { + Console.WriteLine($"[INFO] {string.Format(message, args)}"); + } + + public void Debug(string message, params object[] args) + { + Console.WriteLine($"[DEBUG] {string.Format(message, args)}"); + } +} diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs new file mode 100644 index 0000000..cc58eaa --- /dev/null +++ b/src/SchematicHQ.Client/Schematic.cs @@ -0,0 +1,173 @@ +using OneOf; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +#nullable enable + +namespace SchematicHQ.Client; + +public partial class Schematic +{ + private readonly ClientOptions _options; + private readonly IEventBuffer _eventBuffer; + private readonly ISchematicLogger _logger; + private readonly List> _flagCheckCacheProviders; + private readonly bool _offline; + public readonly SchematicApi API; + + public Schematic(string apiKey, ClientOptions? options = null) + { + _options = options ?? new ClientOptions(); + _offline = _options.Offline; + _logger = _options.Logger ?? new ConsoleLogger(); + + var httpClient = _offline ? new OfflineHttpClient() : _options.HttpClient; + API = new SchematicApi(apiKey, _options.WithHttpClient(httpClient)); + + _eventBuffer = _options.EventBuffer ?? new EventBuffer( + async items => + { + var request = new CreateEventBatchRequestBody + { + Events = items + }; + await API.Events.CreateEventBatchAsync(request); + }, + _logger, + flushPeriod: options.DefaultEventBufferPeriod // default flush period + ); + _eventBuffer.Start(); + + _flagCheckCacheProviders = _options.CacheProviders ?? new List> + { + new LocalCache() + }; + } + + public void Shutdown() + { + _eventBuffer.Dispose(); + } + + public async Task CheckFlag(string flagKey, Dictionary? company = null, Dictionary? user = null) + { + + if (_offline) + return GetFlagDefault(flagKey); + + try + { + string cacheKey = flagKey; + if (company != null && company.Count > 0) + { + cacheKey += ":c-" + string.Join(";", company.Select(kvp => $"{kvp.Key}={kvp.Value}")); + } + + if (user != null && user.Count > 0) + { + cacheKey += ":u-" + string.Join(";", user.Select(kvp => $"{kvp.Key}={kvp.Value}")); + } + + foreach (var provider in _flagCheckCacheProviders) + { + if (provider.Get(cacheKey) is bool cachedValue) + return cachedValue; + } + var requestBody = new CheckFlagRequestBody + { + Company = company, + User = user + }; + + var response = await API.Features.CheckFlagAsync(flagKey, requestBody); + + + + if (response == null){ + + return GetFlagDefault(flagKey); + } + + foreach (var provider in _flagCheckCacheProviders) + { + provider.Set(cacheKey, response.Data.Value); + } + + return response.Data.Value; + } + catch (Exception ex) + { + _logger.Error("Error checking flag: {0}", ex.Message); + + return GetFlagDefault(flagKey); + } + } + + public void Identify(Dictionary keys, EventBodyIdentifyCompany? company = null, string? name = null, Dictionary>>? traits = null) + { + EnqueueEvent(CreateEventRequestBodyEventType.Identify, new EventBodyIdentify + { + Company = company, + Keys = keys, + Name = name, + Traits = traits + }); + } + + public void Track(string eventName, Dictionary? company = null, Dictionary? user = null, Dictionary>>? traits = null) + { + EnqueueEvent(CreateEventRequestBodyEventType.Track, new EventBodyTrack + { + Company = company, + Event = eventName, + Traits = traits, + User = user + }); + } + + private void EnqueueEvent(CreateEventRequestBodyEventType eventType, OneOf body) + { + if (_offline) + return; + + try + { + var eventBody = new CreateEventRequestBody + { + EventType = eventType, + Body = body + }; + + _eventBuffer.Push(eventBody); + } + catch (Exception ex) + { + _logger.Error("Error enqueueing event: {0}", ex.Message); + } + } + + public int GetBufferWaitingEventCount() + { + return this._eventBuffer.GetEventCount(); + } + + private bool GetFlagDefault(string flagKey) + { + return _options.FlagDefaults?.GetValueOrDefault(flagKey) ?? false; + } + +} + +public class OfflineHttpClient : HttpClient +{ + public override HttpResponseMessage Send(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("{\"data\":{}}") + }; + return response; + } +} \ No newline at end of file