From 4e2dc564dd691e0b2a9971b761b01437839b6fdb Mon Sep 17 00:00:00 2001 From: Jan Korf Date: Wed, 8 Apr 2026 13:04:18 +0200 Subject: [PATCH] Socket routing improvements, unit test cleanup (#276) Updated WebSocket message routing improving performance for scenarios with multiple different subscriptions and topics Added AddCommaSeparated helper for Enum value arrays to ParameterCollection Improved EnumConverter performance and removed string allocation for happy path Fixed CreateParamString extension method for ArrayParametersSerialization.Json Fixed Shared GetOrderBookOptions and GetRecentTradeOptions base validations not being called --- .../AsyncResetEventTests.cs | 4 +- .../BodySerializationTests.cs | 43 ++ .../CallResultTests.cs | 13 +- .../{ => ClientTests}/BaseClientTests.cs | 23 +- .../{ => ClientTests}/RestClientTests.cs | 116 ++--- .../ClientTests/SocketClientTests.cs | 177 +++++++ .../ConverterTests/ArrayConverterTests.cs | 109 ++++ .../ConverterTests/BoolConverterTests.cs | 57 ++ .../ConverterTests/DateTimeConverterTests.cs | 119 +++++ .../ConverterTests/DecimalConverterTests.cs | 49 ++ .../ConverterTests/EnumConverterTests.cs | 147 ++++++ .../SharedModelConversionTests.cs | 46 ++ .../CryptoExchange.Net.UnitTests.csproj | 1 + .../ExchangeSymbolCacheTests.cs | 2 +- .../TestAuthenticationProvider.cs | 20 + .../Implementations/TestCredentials.cs | 30 ++ .../Implementations/TestEnvironment.cs | 64 +++ .../TestObject.cs} | 4 +- .../Implementations/TestQuery.cs | 25 + .../Implementations/TestRestApiClient.cs | 64 +++ .../Implementations/TestRestClient.cs | 27 + .../Implementations/TestRestMessageHandler.cs | 33 ++ .../Implementations/TestRestOptions.cs | 27 + .../Implementations/TestSerializerContext.cs | 29 ++ .../Implementations/TestSocketApiClient.cs | 44 ++ .../Implementations/TestSocketClient.cs | 26 + .../Implementations/TestSocketMessage.cs | 16 + .../TestSocketMessageHandler.cs | 33 ++ .../Implementations/TestSocketOptions.cs | 27 + .../Implementations/TestSubscription.cs | 50 ++ CryptoExchange.Net.UnitTests/OptionsTests.cs | 114 ++-- .../ParameterCollectionTests.cs | 331 ++++++++++++ .../SharedQuantityTests.cs | 1 - .../SocketClientTests.cs | 234 --------- .../SocketRoutingTests/QueryRouterTests.cs | 235 +++++++++ .../SocketRoutingTests/RoutingTableTests.cs | 167 ++++++ .../SubscriptionRouterTests.cs | 160 ++++++ .../SystemTextJsonConverterTests.cs | 490 ------------------ .../TestImplementations/TestBaseClient.cs | 88 ---- .../TestImplementations/TestHelpers.cs | 49 -- .../TestImplementations/TestRestClient.cs | 213 -------- .../TestRestMessageHandler.cs | 32 -- .../TestSerializerContext.cs | 17 - .../UriSerializationTests.cs | 105 ++++ .../SystemTextJson/EnumConverter.cs | 117 ++++- .../SystemTextJson/ObjectStringConverter.cs | 2 +- CryptoExchange.Net/ExtensionMethods.cs | 2 +- .../Objects/ParameterCollection.cs | 29 ++ .../Options/Endpoints/GetOrderBookOptions.cs | 2 +- .../Endpoints/GetRecentTradesOptions.cs | 6 +- .../Sockets/Default/Routing/MessageRoute.cs | 104 ++++ .../{ => Default/Routing}/MessageRouter.cs | 135 ++--- .../Default/Routing/ProcessorRouter.cs | 36 ++ .../Sockets/Default/Routing/QueryRouter.cs | 90 ++++ .../Default/Routing/RouteCollection.cs | 65 +++ .../Sockets/Default/Routing/RoutingTable.cs | 111 ++++ .../Default/Routing/SubscriptionRouter.cs | 69 +++ .../Sockets/Default/SocketConnection.cs | 134 ++--- .../Sockets/Default/Subscription.cs | 22 +- .../Sockets/Interfaces/IMessageProcessor.cs | 7 +- CryptoExchange.Net/Sockets/Query.cs | 32 +- .../Testing/EnumValueTraceListener.cs | 6 + .../Testing/SharedRestRequestValidator.cs | 2 + 63 files changed, 3134 insertions(+), 1498 deletions(-) create mode 100644 CryptoExchange.Net.UnitTests/BodySerializationTests.cs rename CryptoExchange.Net.UnitTests/{ => ClientTests}/BaseClientTests.cs (72%) rename CryptoExchange.Net.UnitTests/{ => ClientTests}/RestClientTests.cs (79%) create mode 100644 CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs create mode 100644 CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs create mode 100644 CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs create mode 100644 CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs create mode 100644 CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs create mode 100644 CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs create mode 100644 CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs rename CryptoExchange.Net.UnitTests/{TestImplementations/TestObjects.cs => Implementations/TestObject.cs} (71%) create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs create mode 100644 CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs create mode 100644 CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs delete mode 100644 CryptoExchange.Net.UnitTests/SocketClientTests.cs create mode 100644 CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs create mode 100644 CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs create mode 100644 CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs delete mode 100644 CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs delete mode 100644 CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs delete mode 100644 CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs delete mode 100644 CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs delete mode 100644 CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs delete mode 100644 CryptoExchange.Net.UnitTests/TestSerializerContext.cs create mode 100644 CryptoExchange.Net.UnitTests/UriSerializationTests.cs create mode 100644 CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs rename CryptoExchange.Net/Sockets/{ => Default/Routing}/MessageRouter.cs (67%) create mode 100644 CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs create mode 100644 CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs create mode 100644 CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs create mode 100644 CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs create mode 100644 CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs diff --git a/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs b/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs index 8fb13bc..672cfb3 100644 --- a/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs +++ b/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs @@ -96,7 +96,7 @@ namespace CryptoExchange.Net.UnitTests waiters.Add(evnt.WaitAsync()); } - List results = null; + List? results = null; var resultsWaiter = Task.Run(async () => { await Task.WhenAll(waiters); @@ -112,7 +112,7 @@ namespace CryptoExchange.Net.UnitTests await resultsWaiter; - Assert.That(10 == results.Count(r => r)); + Assert.That(10 == results?.Count(r => r)); } [Test] diff --git a/CryptoExchange.Net.UnitTests/BodySerializationTests.cs b/CryptoExchange.Net.UnitTests/BodySerializationTests.cs new file mode 100644 index 0000000..97947a1 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/BodySerializationTests.cs @@ -0,0 +1,43 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; +using CryptoExchange.Net; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Converters.SystemTextJson; + +namespace CryptoExchange.Net.UnitTests +{ + internal class BodySerializationTests + { + [Test] + public void ToFormData_SerializesBasicValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", "1" }, + { "b", 2 }, + { "c", true } + }; + + var parameterString = parameters.ToFormData(); + + Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True")); + } + + [Test] + public void JsonSerializer_SerializesBasicValuesCorrectly() + { + var serializer = new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext())); + var parameters = new Dictionary() + { + { "a", "1" }, + { "b", 2 }, + { "c", true } + }; + + var parameterString = serializer.Serialize(parameters); + Assert.That(parameterString, Is.EqualTo("{\"a\":\"1\",\"b\":2,\"c\":true}")); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/CallResultTests.cs b/CryptoExchange.Net.UnitTests/CallResultTests.cs index b54bc48..27860bb 100644 --- a/CryptoExchange.Net.UnitTests/CallResultTests.cs +++ b/CryptoExchange.Net.UnitTests/CallResultTests.cs @@ -3,7 +3,6 @@ using CryptoExchange.Net.Objects.Errors; using NUnit.Framework; using NUnit.Framework.Legacy; using System; -using System.Collections.Generic; using System.Net; using System.Net.Http; @@ -17,7 +16,7 @@ namespace CryptoExchange.Net.UnitTests { var result = new CallResult(new ServerError("TestError", ErrorInfo.Unknown)); - ClassicAssert.AreSame(result.Error.ErrorCode, "TestError"); + ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError"); ClassicAssert.IsFalse(result); ClassicAssert.IsFalse(result.Success); } @@ -37,7 +36,7 @@ namespace CryptoExchange.Net.UnitTests { var result = new CallResult(new ServerError("TestError", ErrorInfo.Unknown)); - ClassicAssert.AreSame(result.Error.ErrorCode, "TestError"); + ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError"); ClassicAssert.IsNull(result.Data); ClassicAssert.IsFalse(result); ClassicAssert.IsFalse(result.Success); @@ -74,7 +73,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.As(default); ClassicAssert.IsNotNull(asResult.Error); - ClassicAssert.AreSame(asResult.Error.ErrorCode, "TestError"); + ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError"); ClassicAssert.IsNull(asResult.Data); ClassicAssert.IsFalse(asResult); ClassicAssert.IsFalse(asResult.Success); @@ -87,7 +86,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.AsError(new ServerError("TestError2", ErrorInfo.Unknown)); ClassicAssert.IsNotNull(asResult.Error); - ClassicAssert.AreSame(asResult.Error.ErrorCode, "TestError2"); + ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError2"); ClassicAssert.IsNull(asResult.Data); ClassicAssert.IsFalse(asResult); ClassicAssert.IsFalse(asResult.Success); @@ -100,7 +99,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.AsError(new ServerError("TestError2", ErrorInfo.Unknown)); ClassicAssert.IsNotNull(asResult.Error); - ClassicAssert.AreSame(asResult.Error.ErrorCode, "TestError2"); + ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError2"); ClassicAssert.IsNull(asResult.Data); ClassicAssert.IsFalse(asResult); ClassicAssert.IsFalse(asResult.Success); @@ -127,7 +126,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.AsError(new ServerError("TestError2", ErrorInfo.Unknown)); ClassicAssert.IsNotNull(asResult.Error); - Assert.That(asResult.Error.ErrorCode == "TestError2"); + Assert.That(asResult.Error!.ErrorCode == "TestError2"); Assert.That(asResult.ResponseStatusCode == System.Net.HttpStatusCode.OK); Assert.That(asResult.ResponseTime == TimeSpan.FromSeconds(1)); Assert.That(asResult.RequestUrl == "https://test.com/api"); diff --git a/CryptoExchange.Net.UnitTests/BaseClientTests.cs b/CryptoExchange.Net.UnitTests/ClientTests/BaseClientTests.cs similarity index 72% rename from CryptoExchange.Net.UnitTests/BaseClientTests.cs rename to CryptoExchange.Net.UnitTests/ClientTests/BaseClientTests.cs index e7dfe45..d025316 100644 --- a/CryptoExchange.Net.UnitTests/BaseClientTests.cs +++ b/CryptoExchange.Net.UnitTests/ClientTests/BaseClientTests.cs @@ -1,23 +1,22 @@ using NUnit.Framework; -using NUnit.Framework.Legacy; -namespace CryptoExchange.Net.UnitTests +namespace CryptoExchange.Net.UnitTests.ClientTests { [TestFixture()] public class BaseClientTests { - [TestCase] - public void DeserializingValidJson_Should_GiveSuccessfulResult() - { - // arrange - var client = new TestBaseClient(); + //[TestCase] + //public void DeserializingValidJson_Should_GiveSuccessfulResult() + //{ + // // arrange + // var client = new TestBaseClient(); - // act - var result = client.SubClient.Deserialize("{\"testProperty\": 123}"); + // // act + // var result = client.SubClient.Deserialize("{\"testProperty\": 123}"); - // assert - Assert.That(result.Success); - } + // // assert + // Assert.That(result.Success); + //} [TestCase("https://api.test.com/api", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")] [TestCase("https://api.test.com/api", new[] { "path1", "/path2" }, "https://api.test.com/api/path1/path2")] diff --git a/CryptoExchange.Net.UnitTests/RestClientTests.cs b/CryptoExchange.Net.UnitTests/ClientTests/RestClientTests.cs similarity index 79% rename from CryptoExchange.Net.UnitTests/RestClientTests.cs rename to CryptoExchange.Net.UnitTests/ClientTests/RestClientTests.cs index d17a456..4e36ac4 100644 --- a/CryptoExchange.Net.UnitTests/RestClientTests.cs +++ b/CryptoExchange.Net.UnitTests/ClientTests/RestClientTests.cs @@ -1,9 +1,6 @@ using CryptoExchange.Net.Objects; -using CryptoExchange.Net.UnitTests.TestImplementations; using NUnit.Framework; using System; -using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading.Tasks; using System.Threading; @@ -13,22 +10,25 @@ using CryptoExchange.Net.RateLimiting.Guards; using CryptoExchange.Net.RateLimiting.Filters; using CryptoExchange.Net.RateLimiting.Interfaces; using System.Text.Json; +using CryptoExchange.Net.UnitTests.Implementations; +using CryptoExchange.Net.Testing; -namespace CryptoExchange.Net.UnitTests +namespace CryptoExchange.Net.UnitTests.ClientTests { [TestFixture()] public class RestClientTests { [TestCase] - public void RequestingData_Should_ResultInData() + public async Task RequestingData_Should_ResultInData() { // arrange var client = new TestRestClient(); var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" }; - client.SetResponse(JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }), out _); + var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }); + client.ApiClient1.SetNextResponse(strData, System.Net.HttpStatusCode.OK); // act - var result = client.Api1.Request().Result; + var result = await client.ApiClient1.GetResponseAsync(); // assert Assert.That(result.Success); @@ -36,14 +36,14 @@ namespace CryptoExchange.Net.UnitTests } [TestCase] - public void ReceivingInvalidData_Should_ResultInError() + public async Task ReceivingInvalidData_Should_ResultInError() { // arrange var client = new TestRestClient(); - client.SetResponse("{\"property\": 123", out _); + client.ApiClient1.SetNextResponse("{\"property\": 123", System.Net.HttpStatusCode.OK); // act - var result = client.Api1.Request().Result; + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); @@ -55,10 +55,10 @@ namespace CryptoExchange.Net.UnitTests { // arrange var client = new TestRestClient(); - client.SetErrorWithoutResponse(System.Net.HttpStatusCode.BadRequest, "Invalid request"); + client.ApiClient1.SetNextResponse("Invalid request", System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api1.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); @@ -70,10 +70,10 @@ namespace CryptoExchange.Net.UnitTests { // arrange var client = new TestRestClient(); - client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); + client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api1.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); @@ -81,60 +81,42 @@ namespace CryptoExchange.Net.UnitTests Assert.That(result.Error is ServerError); } - [TestCase] public async Task ReceivingErrorAndNotParsingErrorAndInvalidJson_Should_ContainData() { // arrange var client = new TestRestClient(); var response = "..."; - client.SetErrorWithResponse(response, System.Net.HttpStatusCode.BadRequest); + client.ApiClient1.SetNextResponse(response, System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api1.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); Assert.That(result.Error != null); Assert.That(result.Error is DeserializeError); - Assert.That(result.Error.Message.Contains(response)); + Assert.That(result.Error!.Message!.Contains(response)); } [TestCase] public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError() { // arrange - var client = new ParseErrorTestRestClient(); - client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); + var client = new TestRestClient(); + client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api2.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); Assert.That(result.Error != null); Assert.That(result.Error is ServerError); - Assert.That(result.Error.ErrorCode == "123"); + Assert.That(result.Error!.ErrorCode == "123"); Assert.That(result.Error.Message == "Invalid request"); } - [TestCase] - public void SettingOptions_Should_ResultInOptionsSet() - { - // arrange - // act - var options = new TestClientOptions(); - options.Api1Options.TimestampRecalculationInterval = TimeSpan.FromMinutes(10); - options.Api1Options.OutputOriginalData = true; - options.RequestTimeout = TimeSpan.FromMinutes(1); - var client = new TestBaseClient(options); - - // assert - Assert.That(((TestClientOptions)client.ClientOptions).Api1Options.TimestampRecalculationInterval == TimeSpan.FromMinutes(10)); - Assert.That(((TestClientOptions)client.ClientOptions).Api1Options.OutputOriginalData == true); - Assert.That(((TestClientOptions)client.ClientOptions).RequestTimeout == TimeSpan.FromMinutes(1)); - } - [TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid [TestCase("POST", HttpMethodParameterPosition.InBody)] [TestCase("POST", HttpMethodParameterPosition.InUri)] @@ -148,28 +130,22 @@ namespace CryptoExchange.Net.UnitTests // act var client = new TestRestClient(); - client.Api1.SetParameterPosition(new HttpMethod(method), pos); + var httpMethod = new HttpMethod(method); + client.ApiClient1.SetParameterPosition(httpMethod, pos); + client.ApiClient1.SetNextResponse("{}", System.Net.HttpStatusCode.OK); - client.SetResponse("{}", out var request); - - await client.Api1.RequestWithParams(new HttpMethod(method), new ParameterCollection + var result = await client.ApiClient1.GetResponseAsync(httpMethod, new ParameterCollection { { "TestParam1", "Value1" }, { "TestParam2", 2 }, - }, - new Dictionary - { - { "TestHeader", "123" } }); // assert - Assert.That(request.Method == new HttpMethod(method)); - Assert.That((request.Content?.Contains("TestParam1") == true) == (pos == HttpMethodParameterPosition.InBody)); - Assert.That((request.Uri.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri)); - Assert.That((request.Content?.Contains("TestParam2") == true) == (pos == HttpMethodParameterPosition.InBody)); - Assert.That((request.Uri.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri)); - Assert.That(request.GetHeaders().First().Key == "TestHeader"); - Assert.That(request.GetHeaders().First().Value.Contains("123")); + Assert.That(result.RequestMethod == new HttpMethod(method)); + Assert.That(result.RequestBody?.Contains("TestParam1") == true == (pos == HttpMethodParameterPosition.InBody)); + Assert.That((result.RequestUrl?.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri)); + Assert.That(result.RequestBody?.Contains("TestParam2") == true == (pos == HttpMethodParameterPosition.InBody)); + Assert.That((result.RequestUrl?.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri)); } @@ -188,12 +164,12 @@ namespace CryptoExchange.Net.UnitTests for (var i = 0; i < requests + 1; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - Assert.That(i == requests? triggered : !triggered); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); + Assert.That(i == requests ? triggered : !triggered); } triggered = false; await Task.Delay((int)Math.Round(perSeconds * 1000) + 10); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); Assert.That(!triggered); } @@ -209,12 +185,12 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - bool expected = i == 1 ? (expectLimiting ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null; + bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } } @@ -231,7 +207,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get); var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -271,15 +247,15 @@ namespace CryptoExchange.Net.UnitTests { var rateLimiter = new RateLimitGate("Test"); rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathFilter("/sapi/test"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed)); - + var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - bool expected = i == 1 ? (expectLimited ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null; + bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } } @@ -294,12 +270,12 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathsFilter(new[] { "/sapi/test", "/sapi/test2" }), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed)); var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - bool expected = i == 1 ? (expectLimited ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null; + bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } } @@ -318,7 +294,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get) { Authenticated = key1 != null }; var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = key2 != null }; - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", key1, 1, RateLimitingBehaviour.Wait, null, default); @@ -337,7 +313,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get); var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true }; - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -357,7 +333,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get); var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true }; - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host1, "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -374,7 +350,7 @@ namespace CryptoExchange.Net.UnitTests var rateLimiter = new RateLimitGate("Test"); rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed)); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host1, "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -389,7 +365,7 @@ namespace CryptoExchange.Net.UnitTests var rateLimiter = new RateLimitGate("Test"); rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed)); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2)); diff --git a/CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs b/CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs new file mode 100644 index 0000000..5126809 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs @@ -0,0 +1,177 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Testing; +using CryptoExchange.Net.UnitTests.Implementations; +using NUnit.Framework; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.ClientTests +{ + [TestFixture] + public class SocketClientTests + { + [TestCase] + public void SettingOptions_Should_ResultInOptionsSet() + { + //arrange + //act + var client = new TestSocketClient(options => + { + options.ExchangeOptions.MaxSocketConnections = 1; + }); + + //assert + Assert.That(1 == client.ApiClient1.ApiOptions.MaxSocketConnections); + } + + [TestCase(true)] + [TestCase(false)] + public async Task ConnectSocket_Should_ReturnConnectionResult(bool canConnect) + { + //arrange + var client = new TestSocketClient(); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + socket.CanConnect = canConnect; + + //act + var connectResult = await client.ApiClient1.SubscribeToUpdatesAsync(x => { }, false, default); + + //assert + Assert.That(connectResult.Success == canConnect); + } + + [TestCase] + public async Task SocketMessages_Should_BeProcessedInDataHandlers() + { + var client = new TestSocketClient(); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + + var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" }; + var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }); + + TestObject? received = null; + var resetEvent = new AsyncResetEvent(false); + + await client.ApiClient1.SubscribeToUpdatesAsync(x => + { + received = x.Data; + resetEvent.Set(); + }, false, default); + + socket.InvokeMessage(strData); + await resetEvent.WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.That(received != null); + } + + [TestCase(false)] + [TestCase(true)] + public async Task SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled) + { + // arrange + var client = new TestSocketClient(options => + { + options.ReconnectInterval = TimeSpan.Zero; + options.ExchangeOptions.OutputOriginalData = enabled; + }); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" }; + var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }); + + string? originalData = null; + var resetEvent = new AsyncResetEvent(false); + + await client.ApiClient1.SubscribeToUpdatesAsync(x => + { + originalData = x.OriginalData; + resetEvent.Set(); + }, false, default); + + socket.InvokeMessage(strData); + await resetEvent.WaitAsync(TimeSpan.FromSeconds(1)); + + // assert + Assert.That(originalData == (enabled ? strData : null)); + } + + [TestCase()] + public async Task UnsubscribingStream_Should_CloseTheSocket() + { + // arrange + var client = new TestSocketClient(options => + { + options.ReconnectInterval = TimeSpan.Zero; + }); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + + var result = await client.ApiClient1.SubscribeToUpdatesAsync(x => {}, false, default); + + // act + await client.UnsubscribeAsync(result.Data); + + // assert + Assert.That(socket.Connected == false); + } + + [TestCase()] + public async Task UnsubscribingAll_Should_CloseAllSockets() + { + // arrange + var client = new TestSocketClient(options => + { + options.ReconnectInterval = TimeSpan.Zero; + }); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var result = await client.ApiClient1.SubscribeToUpdatesAsync(x => { }, false, default); + + var socket2 = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var result2 = await client.ApiClient1.SubscribeToUpdatesAsync(x => { }, false, default); + + // act + await client.UnsubscribeAllAsync(); + + // assert + Assert.That(socket.Connected == false); + Assert.That(socket2.Connected == false); + } + + [TestCase()] + public async Task ErrorResponse_ShouldNot_ConfirmSubscription() + { + // arrange + var client = new TestSocketClient(opt => + { + opt.OutputOriginalData = true; + }); + + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var subTask = client.ApiClient1.SubscribeToUpdatesAsync(x => { }, true, default); + + socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "ErrorWithSub" })); + + var result = await subTask; + + // assert + Assert.That(result.Success == false); + Assert.That(result.Error!.Message!.Contains("ErrorWithSub")); + } + + [TestCase()] + public async Task SuccessResponse_Should_ConfirmSubscription() + { + var client = new TestSocketClient(); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var subTask = client.ApiClient1.SubscribeToUpdatesAsync(x => { }, true, default); + + socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "OK" })); + + var result = await subTask; + + var subscription = client.ApiClient1._socketConnections.Single().Value.Subscriptions.Single(); + Assert.That(subscription.Status == SubscriptionStatus.Subscribed); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs new file mode 100644 index 0000000..6069359 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs @@ -0,0 +1,109 @@ +using CryptoExchange.Net.Attributes; +using CryptoExchange.Net.Converters; +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class ArrayConverterTests + { + [Test()] + public void TestArrayConverter() + { + var data = new Test() + { + Prop1 = 2, + Prop2 = null, + Prop3 = "123", + Prop3Again = "123", + Prop4 = null, + Prop5 = new Test2 + { + Prop21 = 3, + Prop22 = "456" + }, + Prop6 = new Test3 + { + Prop31 = 4, + Prop32 = "789" + }, + Prop7 = TestEnum.Two, + TestInternal = new Test + { + Prop1 = 10 + }, + Prop8 = new Test3 + { + Prop31 = 5, + Prop32 = "101" + }, + }; + + var options = new JsonSerializerOptions() + { + TypeInfoResolver = new TestSerializerContext() + }; + var serialized = JsonSerializer.Serialize(data); + var deserialized = JsonSerializer.Deserialize(serialized); + + Assert.That(deserialized!.Prop1, Is.EqualTo(2)); + Assert.That(deserialized.Prop2, Is.Null); + Assert.That(deserialized.Prop3, Is.EqualTo("123")); + Assert.That(deserialized.Prop3Again, Is.EqualTo("123")); + Assert.That(deserialized.Prop4, Is.Null); + Assert.That(deserialized.Prop5!.Prop21, Is.EqualTo(3)); + Assert.That(deserialized.Prop5!.Prop22, Is.EqualTo("456")); + Assert.That(deserialized.Prop6!.Prop31, Is.EqualTo(4)); + Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789")); + Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two)); + Assert.That(deserialized.TestInternal!.Prop1, Is.EqualTo(10)); + Assert.That(deserialized.Prop8!.Prop31, Is.EqualTo(5)); + Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101")); + } + } + + [JsonConverter(typeof(ArrayConverter))] + public record Test + { + [ArrayProperty(0)] + public int Prop1 { get; set; } + [ArrayProperty(1)] + public int? Prop2 { get; set; } + [ArrayProperty(2)] + public string? Prop3 { get; set; } + [ArrayProperty(2)] + public string? Prop3Again { get; set; } + [ArrayProperty(3)] + public string? Prop4 { get; set; } + [ArrayProperty(4)] + public Test2? Prop5 { get; set; } + [ArrayProperty(5)] + public Test3? Prop6 { get; set; } + [ArrayProperty(6), JsonConverter(typeof(EnumConverter))] + public TestEnum? Prop7 { get; set; } + [ArrayProperty(7)] + public Test? TestInternal { get; set; } + [ArrayProperty(8), JsonConversion] + public Test3? Prop8 { get; set; } + } + + [JsonConverter(typeof(ArrayConverter))] + public record Test2 + { + [ArrayProperty(0)] + public int Prop21 { get; set; } + [ArrayProperty(1)] + public string? Prop22 { get; set; } + } + + public record Test3 + { + [JsonPropertyName("prop31")] + public int Prop31 { get; set; } + [JsonPropertyName("prop32")] + public string? Prop32 { get; set; } + } + +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs new file mode 100644 index 0000000..b65d1ed --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs @@ -0,0 +1,57 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System.Text.Json; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class BoolConverterTests + { + [TestCase("1", true)] + [TestCase("true", true)] + [TestCase("yes", true)] + [TestCase("y", true)] + [TestCase("on", true)] + [TestCase("-1", false)] + [TestCase("0", false)] + [TestCase("n", false)] + [TestCase("no", false)] + [TestCase("false", false)] + [TestCase("off", false)] + [TestCase("", null)] + public void TestBoolConverter(string value, bool? expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext())); + Assert.That(output!.Value == expected); + } + + [TestCase("1", true)] + [TestCase("true", true)] + [TestCase("yes", true)] + [TestCase("y", true)] + [TestCase("on", true)] + [TestCase("-1", false)] + [TestCase("0", false)] + [TestCase("n", false)] + [TestCase("no", false)] + [TestCase("false", false)] + [TestCase("off", false)] + [TestCase("", false)] + public void TestBoolConverterNotNullable(string value, bool expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext())); + Assert.That(output!.Value == expected); + } + } + + public class STJBoolObject + { + public bool? Value { get; set; } + } + + public class NotNullableSTJBoolObject + { + public bool Value { get; set; } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs new file mode 100644 index 0000000..4892a05 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs @@ -0,0 +1,119 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class DateTimeConverterTests + { + [TestCase("2021-05-12")] + [TestCase("20210512")] + [TestCase("210512")] + [TestCase("1620777600.000")] + [TestCase("1620777600000")] + [TestCase("2021-05-12T00:00:00.000Z")] + [TestCase("2021-05-12T00:00:00.000000000Z")] + [TestCase("0.000000", true)] + [TestCase("0", true)] + [TestCase("", true)] + [TestCase(" ", true)] + public void TestDateTimeConverterString(string input, bool expectNull = false) + { + var output = JsonSerializer.Deserialize($"{{ \"time\": \"{input}\" }}"); + Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); + } + + [TestCase(1620777600.000)] + [TestCase(1620777600000d)] + public void TestDateTimeConverterDouble(double input) + { + var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); + Assert.That(output!.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [TestCase(1620777600)] + [TestCase(1620777600000)] + [TestCase(1620777600000000)] + [TestCase(1620777600000000000)] + [TestCase(0, true)] + public void TestDateTimeConverterLong(long input, bool expectNull = false) + { + var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); + Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); + } + + [TestCase(1620777600)] + [TestCase(1620777600.000)] + public void TestDateTimeConverterFromSeconds(double input) + { + var output = DateTimeConverter.ConvertFromSeconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToSeconds() + { + var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600); + } + + [TestCase(1620777600000)] + [TestCase(1620777600000.000)] + public void TestDateTimeConverterFromMilliseconds(double input) + { + var output = DateTimeConverter.ConvertFromMilliseconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToMilliseconds() + { + var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600000); + } + + [TestCase(1620777600000000)] + public void TestDateTimeConverterFromMicroseconds(long input) + { + var output = DateTimeConverter.ConvertFromMicroseconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToMicroseconds() + { + var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600000000); + } + + [TestCase(1620777600000000000)] + public void TestDateTimeConverterFromNanoseconds(long input) + { + var output = DateTimeConverter.ConvertFromNanoseconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToNanoseconds() + { + var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600000000000); + } + + [TestCase()] + public void TestDateTimeConverterNull() + { + var output = JsonSerializer.Deserialize($"{{ \"time\": null }}"); + Assert.That(output!.Time == null); + } + } + + public class STJTimeObject + { + [JsonConverter(typeof(DateTimeConverter))] + [JsonPropertyName("time")] + public DateTime? Time { get; set; } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs new file mode 100644 index 0000000..b7302d7 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs @@ -0,0 +1,49 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class DecimalConverterTests + { + [TestCase("1", 1)] + [TestCase("1.1", 1.1)] + [TestCase("-1.1", -1.1)] + [TestCase(null, null)] + [TestCase("", null)] + [TestCase("null", null)] + [TestCase("nan", null)] + [TestCase("1E+2", 100)] + [TestCase("1E-2", 0.01)] + [TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue + [TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue + [TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue + [TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue + public void TestDecimalConverterString(string value, decimal? expected) + { + var result = JsonSerializer.Deserialize("{ \"test\": \"" + value + "\"}"); + Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue : expected)); + } + + [TestCase("1", 1)] + [TestCase("1.1", 1.1)] + [TestCase("-1.1", -1.1)] + [TestCase("null", null)] + [TestCase("1E+2", 100)] + [TestCase("1E-2", 0.01)] + [TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue + public void TestDecimalConverterNumber(string value, decimal? expected) + { + var result = JsonSerializer.Deserialize("{ \"test\": " + value + "}"); + Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected)); + } + } + + public class STJDecimalObject + { + [JsonConverter(typeof(DecimalConverter))] + [JsonPropertyName("test")] + public decimal? Test { get; set; } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs new file mode 100644 index 0000000..ee9b1d8 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs @@ -0,0 +1,147 @@ +using CryptoExchange.Net.Attributes; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Testing; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class EnumConverterTests + { + [TestCase(TestEnum.One, "1")] + [TestCase(TestEnum.Two, "2")] + [TestCase(TestEnum.Three, "three")] + [TestCase(TestEnum.Four, "Four")] + [TestCase(null, null)] + public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected) + { + var output = EnumConverter.GetString(value); + Assert.That(output == expected); + } + + [TestCase(TestEnum.One, "1")] + [TestCase(TestEnum.Two, "2")] + [TestCase(TestEnum.Three, "three")] + [TestCase(TestEnum.Four, "Four")] + public void TestEnumConverterGetStringTests(TestEnum value, string expected) + { + var output = EnumConverter.GetString(value); + Assert.That(output == expected); + } + + [TestCase("1", TestEnum.One)] + [TestCase("2", TestEnum.Two)] + [TestCase("3", TestEnum.Three)] + [TestCase("three", TestEnum.Three)] + [TestCase("Four", TestEnum.Four)] + [TestCase("four", TestEnum.Four)] + [TestCase("Four1", null)] + [TestCase(null, null)] + public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext())); + Assert.That(output!.Value == expected); + } + + [TestCase("1", TestEnum.One)] + [TestCase("2", TestEnum.Two)] + [TestCase("3", TestEnum.Three)] + [TestCase("three", TestEnum.Three)] + [TestCase("Four", TestEnum.Four)] + [TestCase("four", TestEnum.Four)] + [TestCase("Four1", (TestEnum)(-9))] + [TestCase(null, (TestEnum)(-9))] + public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}"); + Assert.That(output!.Value == expected); + } + + [Test] + public void TestEnumConverterMapsUndefinedValueCorrectlyIfDefaultIsDefined() + { + var output = JsonSerializer.Deserialize($"\"TestUndefined\""); + Assert.That((int)output == -99); + } + + [TestCase("1", TestEnum.One)] + [TestCase("2", TestEnum.Two)] + [TestCase("3", TestEnum.Three)] + [TestCase("three", TestEnum.Three)] + [TestCase("Four", TestEnum.Four)] + [TestCase("four", TestEnum.Four)] + [TestCase("Four1", null)] + [TestCase(null, null)] + public void TestEnumConverterParseStringTests(string value, TestEnum? expected) + { + var result = EnumConverter.ParseString(value); + Assert.That(result == expected); + } + + [Test] + public void TestEnumConverterParseNullOnNonNullableOnlyLogsOnce() + { + LibraryHelpers.StaticLogger = new TraceLogger(); + var listener = new EnumValueTraceListener(); + Trace.Listeners.Add(listener); + EnumConverter.Reset(); + try + { + Assert.Throws(() => + { + var result = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext())); + }); + + Assert.DoesNotThrow(() => + { + var result2 = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext())); + }); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + } + public class STJEnumObject + { + public TestEnum? Value { get; set; } + } + + public class NotNullableSTJEnumObject + { + public TestEnum Value { get; set; } + } + + [JsonConverter(typeof(EnumConverter))] + public enum TestEnum + { + [Map("1")] + One, + [Map("2")] + Two, + [Map("three", "3")] + Three, + Four + } + + [JsonConverter(typeof(EnumConverter))] + public enum TestEnum2 + { + [Map("-9")] + Minus9 = -9, + [Map("1")] + One, + [Map("2")] + Two, + [Map("three", "3")] + Three, + Four + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs new file mode 100644 index 0000000..4d788cd --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs @@ -0,0 +1,46 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.SharedApis; +using NUnit.Framework; +using System; +using System.Text.Json; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + [TestFixture()] + public class SharedModelConversionTests + { + [TestCase(TradingMode.Spot, "ETH", "USDT", null)] + [TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)] + [TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)] + public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime) + { + DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value); + var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time); + + var serialized = JsonSerializer.Serialize(symbol); + var restored = JsonSerializer.Deserialize(serialized); + + Assert.That(restored!.TradingMode, Is.EqualTo(symbol.TradingMode)); + Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset)); + Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset)); + Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime)); + } + + [TestCase(0.1, null, null)] + [TestCase(0.1, 0.1, null)] + [TestCase(0.1, 0.1, 0.1)] + [TestCase(null, 0.1, null)] + [TestCase(null, 0.1, 0.1)] + public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity) + { + var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity); + + var serialized = JsonSerializer.Serialize(symbol); + var restored = JsonSerializer.Deserialize(serialized); + + Assert.That(restored!.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset)); + Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset)); + Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts)); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj b/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj index b29899c..05feaa0 100644 --- a/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj +++ b/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj @@ -3,6 +3,7 @@ net10.0 false + enable diff --git a/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs b/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs index c11c41b..61dda9e 100644 --- a/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs +++ b/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs @@ -326,7 +326,7 @@ namespace CryptoExchange.Net.UnitTests // assert Assert.That(result, Is.Not.Null); - Assert.That(result.BaseAsset, Is.EqualTo("BTC")); + Assert.That(result!.BaseAsset, Is.EqualTo("BTC")); Assert.That(result.QuoteAsset, Is.EqualTo("USDT")); Assert.That(result.TradingMode, Is.EqualTo(TradingMode.Spot)); Assert.That(result.SymbolName, Is.EqualTo("BTCUSDT")); diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs b/CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs new file mode 100644 index 0000000..8027c0f --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs @@ -0,0 +1,20 @@ +using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Objects; +using System.Collections.Generic; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestAuthenticationProvider : AuthenticationProvider + { + public TestAuthenticationProvider(TestCredentials credentials) : base(credentials, credentials) + { + } + + public override void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig) + { + requestConfig.Headers ??= new Dictionary(); + requestConfig.Headers["Authorization"] = Credential.Key; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs b/CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs new file mode 100644 index 0000000..d995f4f --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs @@ -0,0 +1,30 @@ +using CryptoExchange.Net.Authentication; +using System; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestCredentials : HMACCredential + { + public TestCredentials() { } + + public TestCredentials(string key, string secret) : base(key, secret) + { + } + + public TestCredentials(HMACCredential credential) : base(credential.Key, credential.Secret) + { + } + + public TestCredentials WithHMAC(string key, string secret) + { + if (!string.IsNullOrEmpty(Key)) throw new InvalidOperationException("Credentials already set"); + + Key = key; + Secret = secret; + return this; + } + + /// + public override ApiCredentials Copy() => new TestCredentials(this); + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs b/CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs new file mode 100644 index 0000000..6caeddd --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs @@ -0,0 +1,64 @@ +using CryptoExchange.Net.Objects; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestEnvironment : TradeEnvironment + { + public string RestClientAddress { get; } + public string SocketClientAddress { get; } + + internal TestEnvironment( + string name, + string restAddress, + string streamAddress) : + base(name) + { + RestClientAddress = restAddress; + SocketClientAddress = streamAddress; + } + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + public TestEnvironment() : base(TradeEnvironmentNames.Live) +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + { } + + /// + /// Get the environment by name + /// + public static TestEnvironment? GetEnvironmentByName(string? name) + => name switch + { + TradeEnvironmentNames.Live => Live, + "" => Live, + null => Live, + _ => default + }; + + /// + /// Available environment names + /// + /// + public static string[] All => [Live.Name]; + + /// + /// Live environment + /// + public static TestEnvironment Live { get; } + = new TestEnvironment(TradeEnvironmentNames.Live, + "https://localhost", + "wss://localhost"); + + /// + /// Create a custom environment + /// + /// + /// + /// + /// + public static TestEnvironment CreateCustom( + string name, + string spotRestAddress, + string spotSocketStreamsAddress) + => new TestEnvironment(name, spotRestAddress, spotSocketStreamsAddress); + } +} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestObjects.cs b/CryptoExchange.Net.UnitTests/Implementations/TestObject.cs similarity index 71% rename from CryptoExchange.Net.UnitTests/TestImplementations/TestObjects.cs rename to CryptoExchange.Net.UnitTests/Implementations/TestObject.cs index 7827d0c..99006fa 100644 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestObjects.cs +++ b/CryptoExchange.Net.UnitTests/Implementations/TestObject.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; -namespace CryptoExchange.Net.UnitTests.TestImplementations +namespace CryptoExchange.Net.UnitTests.Implementations { public class TestObject { [JsonPropertyName("other")] - public string StringData { get; set; } + public string StringData { get; set; } = string.Empty; [JsonPropertyName("intData")] public int IntData { get; set; } [JsonPropertyName("decimalData")] diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs b/CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs new file mode 100644 index 0000000..05dd240 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs @@ -0,0 +1,25 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Errors; +using CryptoExchange.Net.Sockets; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using System; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestQuery : Query + { + public TestQuery(TestSocketMessage request, bool authenticated) : base(request, authenticated, 1) + { + MessageRouter = MessageRouter.CreateWithoutTopicFilter(request.Id.ToString(), HandleMessage); + } + + private CallResult? HandleMessage(SocketConnection connection, DateTime time, string? arg3, TestSocketMessage message) + { + if (message.Data != "OK") + return new CallResult(new ServerError(ErrorInfo.Unknown with { Message = message.Data })); + + return CallResult.SuccessResult; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs new file mode 100644 index 0000000..a89a7b4 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs @@ -0,0 +1,64 @@ +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.SharedApis; +using CryptoExchange.Net.Testing.Implementations; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestApiClient : RestApiClient + { + protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler(); + + public TestRestApiClient(ILogger logger, HttpClient? httpClient, TestRestOptions options) + : base(logger, httpClient, options.Environment.RestClientAddress, options, options.ExchangeOptions) + { + } + + + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) => + baseAsset + quoteAsset; + + protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) => + new TestAuthenticationProvider(credentials); + + protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext())); + + internal void SetNextResponse(string data, HttpStatusCode code) + { + var expectedBytes = Encoding.UTF8.GetBytes(data); + var responseStream = new MemoryStream(); + responseStream.Write(expectedBytes, 0, expectedBytes.Length); + responseStream.Seek(0, SeekOrigin.Begin); + + var response = new TestResponse(code, responseStream); + var request = new TestRequest(response); + + var factory = new TestRequestFactory(request); + RequestFactory = factory; + } + + internal async Task> GetResponseAsync(HttpMethod? httpMethod = null, ParameterCollection? collection = null) + { + var definition = new RequestDefinition("/path", httpMethod ?? HttpMethod.Get) + { + Weight = 0 + }; + return await SendAsync(BaseAddress, definition, collection ?? new ParameterCollection(), default); + } + + internal void SetParameterPosition(HttpMethod httpMethod, HttpMethodParameterPosition pos) + { + ParameterPositions[httpMethod] = pos; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs new file mode 100644 index 0000000..e812220 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs @@ -0,0 +1,27 @@ +using CryptoExchange.Net.Clients; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Net.Http; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestClient : BaseRestClient + { + public TestRestApiClient ApiClient1 { get; set; } + public TestRestApiClient ApiClient2 { get; set; } + + public TestRestClient(Action? optionsDelegate = null) + : this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate))) + { + } + + public TestRestClient(HttpClient? httpClient, ILoggerFactory? loggerFactory, IOptions options) : base(loggerFactory, "Test") + { + Initialize(options.Value); + + ApiClient1 = AddApiClient(new TestRestApiClient(_logger, httpClient, options.Value)); + ApiClient2 = AddApiClient(new TestRestApiClient(_logger, httpClient, options.Value)); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs new file mode 100644 index 0000000..9bf8d0a --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs @@ -0,0 +1,33 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Errors; +using System.IO; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestMessageHandler : JsonRestMessageHandler + { + public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext()); + + public override async ValueTask ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream) + { + var (jsonError, jsonDocument) = await GetJsonDocument(responseStream).ConfigureAwait(false); + if (jsonError != null) + return jsonError; + + int? code = jsonDocument!.RootElement.TryGetProperty("errorCode", out var codeProp) ? codeProp.GetInt32() : null; + var msg = jsonDocument.RootElement.TryGetProperty("errorMessage", out var msgProp) ? msgProp.GetString() : null; + if (msg == null) + return new ServerError(ErrorInfo.Unknown); + + if (code == null) + return new ServerError(ErrorInfo.Unknown with { Message = msg }); + + return new ServerError(code.Value, new ErrorInfo(ErrorType.Unknown, false, "Error") with { Message = msg }); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs new file mode 100644 index 0000000..7547208 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs @@ -0,0 +1,27 @@ +using CryptoExchange.Net.Objects.Options; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestOptions : RestExchangeOptions + { + internal static TestRestOptions Default { get; set; } = new TestRestOptions() + { + Environment = TestEnvironment.Live, + AutoTimestamp = true + }; + + public TestRestOptions() + { + Default?.Set(this); + } + + public RestApiOptions ExchangeOptions { get; private set; } = new RestApiOptions(); + + internal TestRestOptions Set(TestRestOptions targetOptions) + { + targetOptions = base.Set(targetOptions); + targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions); + return targetOptions; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs new file mode 100644 index 0000000..6dd617c --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs @@ -0,0 +1,29 @@ +using CryptoExchange.Net.UnitTests.ConverterTests; +using CryptoExchange.Net.UnitTests.Implementations; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests +{ + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(TestObject))] + + [JsonSerializable(typeof(TestSocketMessage))] + [JsonSerializable(typeof(Test))] + [JsonSerializable(typeof(Test2))] + [JsonSerializable(typeof(Test3))] + [JsonSerializable(typeof(NotNullableSTJBoolObject))] + [JsonSerializable(typeof(STJBoolObject))] + [JsonSerializable(typeof(NotNullableSTJEnumObject))] + [JsonSerializable(typeof(STJEnumObject))] + [JsonSerializable(typeof(STJDecimalObject))] + [JsonSerializable(typeof(STJTimeObject))] + internal partial class TestSerializerContext : JsonSerializerContext + { + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs new file mode 100644 index 0000000..6f4a034 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs @@ -0,0 +1,44 @@ +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Options; +using CryptoExchange.Net.Objects.Sockets; +using CryptoExchange.Net.SharedApis; +using Microsoft.Extensions.Logging; +using System; +using System.Net.Http; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketApiClient : SocketApiClient + { + public TestSocketApiClient(ILogger logger, TestSocketOptions options) + : base(logger, options.Environment.SocketClientAddress, options, options.ExchangeOptions) + { + } + + public TestSocketApiClient(ILogger logger, HttpClient httpClient, string baseAddress, TestSocketOptions options, SocketApiOptions apiOptions) + : base(logger, baseAddress, options, apiOptions) + { + } + + public override ISocketMessageHandler CreateMessageConverter(WebSocketMessageType messageType) => new TestSocketMessageHandler(); + protected internal override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext())); + + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) => + baseAsset + quoteAsset; + + protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) => + new TestAuthenticationProvider(credentials); + + public async Task> SubscribeToUpdatesAsync(Action> handler, bool subQuery, CancellationToken ct) + { + return await base.SubscribeAsync(new TestSubscription(_logger, handler, subQuery, false), ct); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs new file mode 100644 index 0000000..3b45729 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs @@ -0,0 +1,26 @@ +using CryptoExchange.Net.Clients; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketClient : BaseSocketClient + { + public TestSocketApiClient ApiClient1 { get; set; } + public TestSocketApiClient ApiClient2 { get; set; } + + public TestSocketClient(Action? optionsDelegate = null) + : this(null, Options.Create(ApplyOptionsDelegate(optionsDelegate))) + { + } + + public TestSocketClient(ILoggerFactory? loggerFactory, IOptions options) : base(loggerFactory, "Test") + { + Initialize(options.Value); + + ApiClient1 = AddApiClient(new TestSocketApiClient(_logger, options.Value)); + ApiClient2 = AddApiClient(new TestSocketApiClient(_logger, options.Value)); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs new file mode 100644 index 0000000..5a38b6c --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal record TestSocketMessage + { + [JsonPropertyName("id")] + public int Id { get; set; } + [JsonPropertyName("data")] + public string Data { get; set; } = string.Empty; + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs new file mode 100644 index 0000000..9acc48b --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs @@ -0,0 +1,33 @@ +using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers; +using System.Text.Json; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketMessageHandler : JsonSocketMessageHandler + { + public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext()); + + public TestSocketMessageHandler() + { + } + + protected override MessageTypeDefinition[] TypeEvaluators { get; } = [ + + new MessageTypeDefinition { + ForceIfFound = true, + Fields = [ + new PropertyFieldReference("id") + ], + TypeIdentifierCallback = (doc) => doc.FieldValue("id")! + }, + + new MessageTypeDefinition { + Fields = [ + ], + StaticIdentifier = "test" + }, + ]; + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs new file mode 100644 index 0000000..1193f26 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs @@ -0,0 +1,27 @@ +using CryptoExchange.Net.Objects.Options; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketOptions : SocketExchangeOptions + { + internal static TestSocketOptions Default { get; set; } = new TestSocketOptions() + { + Environment = TestEnvironment.Live, + AutoTimestamp = true + }; + + public TestSocketOptions() + { + Default?.Set(this); + } + + public SocketApiOptions ExchangeOptions { get; private set; } = new SocketApiOptions(); + + internal TestSocketOptions Set(TestSocketOptions targetOptions) + { + targetOptions = base.Set(targetOptions); + targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions); + return targetOptions; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs new file mode 100644 index 0000000..1ff2a4d --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs @@ -0,0 +1,50 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Sockets; +using CryptoExchange.Net.Sockets; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Text; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSubscription : Subscription + { + private readonly Action> _handler; + private bool _subQuery; + + public TestSubscription(ILogger logger, Action> handler, bool subQuery, bool authenticated) : base(logger, authenticated, true) + { + _handler = handler; + _subQuery = subQuery; + + MessageRouter = MessageRouter.CreateWithoutTopicFilter("test", HandleUpdate); + } + + protected override Query? GetSubQuery(SocketConnection connection) + { + if (!_subQuery) + return null; + + return new TestQuery(new TestSocketMessage { Id = 1, Data = "Sub" }, false); + } + + protected override Query? GetUnsubQuery(SocketConnection connection) + { + if (!_subQuery) + return null; + + return new TestQuery(new TestSocketMessage { Id = 2, Data = "Unsub" }, false); + } + + + private CallResult? HandleUpdate(SocketConnection connection, DateTime time, string? originalData, T data) + { + _handler(new DataEvent("Test", data, time, originalData)); + return CallResult.SuccessResult; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/OptionsTests.cs b/CryptoExchange.Net.UnitTests/OptionsTests.cs index 59cb299..823e121 100644 --- a/CryptoExchange.Net.UnitTests/OptionsTests.cs +++ b/CryptoExchange.Net.UnitTests/OptionsTests.cs @@ -1,7 +1,7 @@ using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Objects.Options; -using CryptoExchange.Net.UnitTests.TestImplementations; +using CryptoExchange.Net.UnitTests.Implementations; using NUnit.Framework; using System; @@ -11,9 +11,9 @@ namespace CryptoExchange.Net.UnitTests public class OptionsTests { [TearDown] - public void Init() + public void TearDown() { - TestClientOptions.Default = new TestClientOptions + TestRestOptions.Default = new TestRestOptions { }; } @@ -31,9 +31,9 @@ namespace CryptoExchange.Net.UnitTests // assert Assert.Throws(typeof(ArgumentException), () => { - var opts = new RestExchangeOptions() + var opts = new TestRestOptions() { - ApiCredentials = new HMACCredential(key, secret) + ApiCredentials = new TestCredentials(key, secret) }; opts.ApiCredentials.Validate(); }); @@ -43,14 +43,14 @@ namespace CryptoExchange.Net.UnitTests public void TestBasicOptionsAreSet() { // arrange, act - var options = new TestClientOptions + var options = new TestRestOptions { - ApiCredentials = new HMACCredential("123", "456"), - ReceiveWindow = TimeSpan.FromSeconds(10) + ApiCredentials = new TestCredentials("123", "456"), + RequestTimeout = TimeSpan.FromSeconds(10) }; // assert - Assert.That(options.ReceiveWindow == TimeSpan.FromSeconds(10)); + Assert.That(options.RequestTimeout == TimeSpan.FromSeconds(10)); Assert.That(options.ApiCredentials.Key == "123"); Assert.That(options.ApiCredentials.Secret == "456"); } @@ -65,88 +65,88 @@ namespace CryptoExchange.Net.UnitTests Proxy = new ApiProxy("http://testproxy", 1234) }); - Assert.That(client.Api1.ClientOptions.Proxy, Is.Not.Null); - Assert.That(client.Api1.ClientOptions.Proxy.Host, Is.EqualTo("http://testproxy")); - Assert.That(client.Api1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); - Assert.That(client.Api1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); + Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null); + Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy")); + Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); + Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); } [Test] public void TestSetOptionsRestWithCredentials() { var client = new TestRestClient(); - client.SetOptions(new UpdateOptions + client.SetOptions(new UpdateOptions { - ApiCredentials = new HMACCredential("123", "456"), + ApiCredentials = new TestCredentials("123", "456"), RequestTimeout = TimeSpan.FromSeconds(2), Proxy = new ApiProxy("http://testproxy", 1234) }); - Assert.That(client.Api1.ApiCredentials, Is.Not.Null); - Assert.That(client.Api1.ApiCredentials.Key, Is.EqualTo("123")); - Assert.That(client.Api1.ClientOptions.Proxy, Is.Not.Null); - Assert.That(client.Api1.ClientOptions.Proxy.Host, Is.EqualTo("http://testproxy")); - Assert.That(client.Api1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); - Assert.That(client.Api1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); + Assert.That(client.ApiClient1.ApiCredentials, Is.Not.Null); + Assert.That(client.ApiClient1.ApiCredentials!.Key, Is.EqualTo("123")); + Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null); + Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy")); + Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); + Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); } [Test] public void TestWhenUpdatingSettingsExistingClientsAreNotAffected() { - TestClientOptions.Default = new TestClientOptions + TestRestOptions.Default = new TestRestOptions { - ApiCredentials = new HMACCredential("111", "222"), + ApiCredentials = new TestCredentials("111", "222"), RequestTimeout = TimeSpan.FromSeconds(1), }; var client1 = new TestRestClient(); Assert.That(client1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(1))); - Assert.That(client1.ClientOptions.ApiCredentials.Key, Is.EqualTo("111")); + Assert.That(client1.ClientOptions.ApiCredentials!.Key, Is.EqualTo("111")); - TestClientOptions.Default.ApiCredentials = new HMACCredential("333", "444"); - TestClientOptions.Default.RequestTimeout = TimeSpan.FromSeconds(2); + TestRestOptions.Default.ApiCredentials = new TestCredentials("333", "444"); + TestRestOptions.Default.RequestTimeout = TimeSpan.FromSeconds(2); var client2 = new TestRestClient(); Assert.That(client2.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); - Assert.That(client2.ClientOptions.ApiCredentials.Key, Is.EqualTo("333")); + Assert.That(client2.ClientOptions.ApiCredentials!.Key, Is.EqualTo("333")); } } - public class TestClientOptions: RestExchangeOptions - { - /// - /// Default options for the futures client - /// - public static TestClientOptions Default { get; set; } = new TestClientOptions() - { - Environment = new TestEnvironment("test", "https://test.com") - }; + //public class TestClientOptions: RestExchangeOptions + //{ + // /// + // /// Default options for the futures client + // /// + // public static TestClientOptions Default { get; set; } = new TestClientOptions() + // { + // Environment = new TestEnvironment("test", "https://test.com") + // }; - /// - /// ctor - /// - public TestClientOptions() - { - Default?.Set(this); - } + // /// + // /// ctor + // /// + // public TestClientOptions() + // { + // Default?.Set(this); + // } - /// - /// The default receive window for requests - /// - public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5); + // /// + // /// The default receive window for requests + // /// + // public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5); - public RestApiOptions Api1Options { get; private set; } = new RestApiOptions(); + // public RestApiOptions Api1Options { get; private set; } = new RestApiOptions(); - public RestApiOptions Api2Options { get; set; } = new RestApiOptions(); + // public RestApiOptions Api2Options { get; set; } = new RestApiOptions(); - internal TestClientOptions Set(TestClientOptions targetOptions) - { - targetOptions = base.Set(targetOptions); - targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options); - targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options); - return targetOptions; - } - } + // internal TestClientOptions Set(TestClientOptions targetOptions) + // { + // targetOptions = base.Set(targetOptions); + // targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options); + // targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options); + // return targetOptions; + // } + //} } diff --git a/CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs b/CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs new file mode 100644 index 0000000..2a3f83b --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs @@ -0,0 +1,331 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.UnitTests.ConverterTests; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CryptoExchange.Net.UnitTests +{ + internal class ParameterCollectionTests + { + [Test] + public void AddingBasicValue_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.Add("test", "value"); + Assert.That(parameters["test"], Is.EqualTo("value")); + } + + [Test] + public void AddingBasicNullValue_ThrowsException() + { + var parameters = new ParameterCollection(); + Assert.Throws(() => parameters.Add("test", null!)); + } + + [Test] + public void AddingOptionalBasicValue_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptional("test", "value"); + Assert.That(parameters["test"], Is.EqualTo("value")); + } + + [Test] + public void AddingOptionalBasicNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptional("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingDecimalValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddString("test", 0.1m); + Assert.That(parameters["test"], Is.EqualTo("0.1")); + } + + [Test] + public void AddingOptionalDecimalValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", 0.1m); + Assert.That(parameters["test"], Is.EqualTo("0.1")); + } + + [Test] + public void AddingOptionalDecimalNullValueAsString_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", (decimal?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingIntValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddString("test", 1); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalIntValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", 1); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalIntNullValueAsString_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", (int?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingLongValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddString("test", 1L); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalLongValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", 1L); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalLongNullValueAsString_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", (long?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingMillisecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddMilliseconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600000)); + } + + [Test] + public void AddingOptionalMillisecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMilliseconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600000)); + } + + [Test] + public void AddingOptionalMillisecondNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMilliseconds("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingMillisecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddMillisecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600000")); + } + + [Test] + public void AddingOptionalMillisecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMillisecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600000")); + } + + [Test] + public void AddingOptionalMillisecondStringNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMillisecondsString("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingSecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddSeconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600)); + } + + [Test] + public void AddingOptionalSecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSeconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600)); + } + + [Test] + public void AddingSecondNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSeconds("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingSecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddSecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600")); + } + + [Test] + public void AddingOptionalSecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600")); + } + + [Test] + public void AddingSecondStringNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSecondsString("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddEnum("test", TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo("2")); + } + + [Test] + public void AddingOptionalEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnum("test", (TestEnum?)TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo("2")); + } + + [Test] + public void AddingOptionalEnumNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnum("test", (TestEnum?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingEnumAsInt_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddEnumAsInt("test", TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo(2)); + } + + [Test] + public void AddingOptionalEnumAsInt_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnumAsInt("test", (TestEnum?)TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo(2)); + } + + [Test] + public void AddingOptionalEnumAsIntNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnumAsInt("test", (TestEnum?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingCommaSeparated_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddCommaSeparated("test", ["1", "2"]); + Assert.That(parameters["test"], Is.EqualTo("1,2")); + } + + [Test] + public void AddingOptionalCommaSeparated_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", ["1", "2"]); + Assert.That(parameters["test"], Is.EqualTo("1,2")); + } + + [Test] + public void AddingOptionalCommaSeparatedNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", (string[]?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingCommaSeparatedEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddCommaSeparated("test", [TestEnum.Two, TestEnum.One]); + Assert.That(parameters["test"], Is.EqualTo("2,1")); + } + + [Test] + public void AddingOptionalCommaSeparatedEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", [TestEnum.Two, TestEnum.One]); + Assert.That(parameters["test"], Is.EqualTo("2,1")); + } + + [Test] + public void AddingOptionalCommaSeparatedEnumNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", (TestEnum[]?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingBoolString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddBoolString("test", true); + Assert.That(parameters["test"], Is.EqualTo("true")); + } + + [Test] + public void AddingOptionalBoolString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalBoolString("test", true); + Assert.That(parameters["test"], Is.EqualTo("true")); + } + + [Test] + public void AddingOptionalBoolStringNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalBoolString("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs b/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs index ec65f97..79d9cea 100644 --- a/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs +++ b/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs @@ -1,6 +1,5 @@ using CryptoExchange.Net.SharedApis; using NUnit.Framework; -using System; namespace CryptoExchange.Net.UnitTests { diff --git a/CryptoExchange.Net.UnitTests/SocketClientTests.cs b/CryptoExchange.Net.UnitTests/SocketClientTests.cs deleted file mode 100644 index 97ce6a6..0000000 --- a/CryptoExchange.Net.UnitTests/SocketClientTests.cs +++ /dev/null @@ -1,234 +0,0 @@ -//using CryptoExchange.Net.Objects; -//using CryptoExchange.Net.Objects.Sockets; -//using CryptoExchange.Net.Sockets; -//using CryptoExchange.Net.Testing.Implementations; -//using CryptoExchange.Net.UnitTests.TestImplementations; -//using CryptoExchange.Net.UnitTests.TestImplementations.Sockets; -//using Microsoft.Extensions.Logging; -//using Moq; -//using NUnit.Framework; -//using NUnit.Framework.Legacy; -//using System; -//using System.Collections.Generic; -//using System.Net.Sockets; -//using System.Text.Json; -//using System.Threading; -//using System.Threading.Tasks; - -//namespace CryptoExchange.Net.UnitTests -//{ -// [TestFixture] -// public class SocketClientTests -// { -// [TestCase] -// public void SettingOptions_Should_ResultInOptionsSet() -// { -// //arrange -// //act -// var client = new TestSocketClient(options => -// { -// options.SubOptions.ApiCredentials = new Authentication.ApiCredentials("1", "2"); -// options.SubOptions.MaxSocketConnections = 1; -// }); - -// //assert -// ClassicAssert.NotNull(client.SubClient.ApiOptions.ApiCredentials); -// Assert.That(1 == client.SubClient.ApiOptions.MaxSocketConnections); -// } - -// [TestCase(true)] -// [TestCase(false)] -// public void ConnectSocket_Should_ReturnConnectionResult(bool canConnect) -// { -// //arrange -// var client = new TestSocketClient(); -// var socket = client.CreateSocket(); -// socket.CanConnect = canConnect; - -// //act -// var connectResult = client.SubClient.ConnectSocketSub( -// new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, "")); - -// //assert -// Assert.That(connectResult.Success == canConnect); -// } - -// [TestCase] -// public void SocketMessages_Should_BeProcessedInDataHandlers() -// { -// // arrange -// var client = new TestSocketClient(options => { -// options.ReconnectInterval = TimeSpan.Zero; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// var sub = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// var rstEvent = new ManualResetEvent(false); -// Dictionary result = null; - -// client.SubClient.ConnectSocketSub(sub); - -// var subObj = new TestSubscription>(Mock.Of(), (messageEvent) => -// { -// result = messageEvent.Data; -// rstEvent.Set(); -// }); -// sub.AddSubscription(subObj); - -// // act -// socket.InvokeMessage("{\"property\": \"123\", \"action\": \"update\", \"topic\": \"topic\"}"); -// rstEvent.WaitOne(1000); - -// // assert -// Assert.That(result["property"] == "123"); -// } - -// [TestCase(false)] -// [TestCase(true)] -// public void SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled) -// { -// // arrange -// var client = new TestSocketClient(options => -// { -// options.ReconnectInterval = TimeSpan.Zero; -// options.SubOptions.OutputOriginalData = enabled; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// var sub = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// var rstEvent = new ManualResetEvent(false); -// string original = null; - -// client.SubClient.ConnectSocketSub(sub); -// var subObj = new TestSubscription>(Mock.Of(), (messageEvent) => -// { -// original = messageEvent.OriginalData; -// rstEvent.Set(); -// }); -// sub.AddSubscription(subObj); -// var msgToSend = JsonSerializer.Serialize(new { topic = "topic", action = "update", property = "123" }); - -// // act -// socket.InvokeMessage(msgToSend); -// rstEvent.WaitOne(1000); - -// // assert -// Assert.That(original == (enabled ? msgToSend : null)); -// } - -// [TestCase()] -// public void UnsubscribingStream_Should_CloseTheSocket() -// { -// // arrange -// var client = new TestSocketClient(options => -// { -// options.ReconnectInterval = TimeSpan.Zero; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// var sub = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// client.SubClient.ConnectSocketSub(sub); - -// var subscription = new TestSubscription>(Mock.Of(), (messageEvent) => { }); -// var ups = new UpdateSubscription(sub, subscription); -// sub.AddSubscription(subscription); - -// // act -// client.UnsubscribeAsync(ups).Wait(); - -// // assert -// Assert.That(socket.Connected == false); -// } - -// [TestCase()] -// public void UnsubscribingAll_Should_CloseAllSockets() -// { -// // arrange -// var client = new TestSocketClient(options => { options.ReconnectInterval = TimeSpan.Zero; }); -// var socket1 = client.CreateSocket(); -// var socket2 = client.CreateSocket(); -// socket1.CanConnect = true; -// socket2.CanConnect = true; -// var sub1 = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket1), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// var sub2 = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket2), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// client.SubClient.ConnectSocketSub(sub1); -// client.SubClient.ConnectSocketSub(sub2); -// var subscription1 = new TestSubscription>(Mock.Of(), (messageEvent) => { }); -// var subscription2 = new TestSubscription>(Mock.Of(), (messageEvent) => { }); - -// sub1.AddSubscription(subscription1); -// sub2.AddSubscription(subscription2); -// var ups1 = new UpdateSubscription(sub1, subscription1); -// var ups2 = new UpdateSubscription(sub2, subscription2); - -// // act -// client.UnsubscribeAllAsync().Wait(); - -// // assert -// Assert.That(socket1.Connected == false); -// Assert.That(socket2.Connected == false); -// } - -// [TestCase()] -// public void FailingToConnectSocket_Should_ReturnError() -// { -// // arrange -// var client = new TestSocketClient(options => { options.ReconnectInterval = TimeSpan.Zero; }); -// var socket = client.CreateSocket(); -// socket.CanConnect = false; -// var sub1 = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); - -// // act -// var connectResult = client.SubClient.ConnectSocketSub(sub1); - -// // assert -// ClassicAssert.IsFalse(connectResult.Success); -// } - -// [TestCase()] -// public async Task ErrorResponse_ShouldNot_ConfirmSubscription() -// { -// // arrange -// var channel = "trade_btcusd"; -// var client = new TestSocketClient(opt => -// { -// opt.OutputOriginalData = true; -// opt.SocketSubscriptionsCombineTarget = 1; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, "")); - -// // act -// var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default); -// socket.InvokeMessage(JsonSerializer.Serialize(new { channel, action = "subscribe", status = "error" })); -// await sub; - -// // assert -// ClassicAssert.IsTrue(client.SubClient.TestSubscription.Status != SubscriptionStatus.Subscribed); -// } - -// [TestCase()] -// public async Task SuccessResponse_Should_ConfirmSubscription() -// { -// // arrange -// var channel = "trade_btcusd"; -// var client = new TestSocketClient(opt => -// { -// opt.OutputOriginalData = true; -// opt.SocketSubscriptionsCombineTarget = 1; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, "")); - -// // act -// var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default); -// socket.InvokeMessage(JsonSerializer.Serialize(new { channel, action = "subscribe", status = "confirmed" })); -// await sub; - -// // assert -// Assert.That(client.SubClient.TestSubscription.Status == SubscriptionStatus.Subscribed); -// } -// } -//} diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs new file mode 100644 index 0000000..ce37219 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs @@ -0,0 +1,235 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using NUnit.Framework; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class QueryRouterTests + { + [Test] + public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType() + { + // arrange + var routes = new MessageRoute[] + { + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null) + }; + + var router = new QueryRouter(routes); + + // act + var type1Routes = router.GetRoutes("type1"); + var type2Routes = router.GetRoutes("type2"); + var missingRoutes = router.GetRoutes("missing"); + + // assert + Assert.That(type1Routes, Is.Not.Null); + Assert.That(type2Routes, Is.Not.Null); + Assert.That(missingRoutes, Is.Null); + + Assert.That(type1Routes, Is.TypeOf()); + Assert.That(type2Routes, Is.TypeOf()); + Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int))); + } + + [Test] + public void AddRoute_Should_SetMultipleReaders_WhenAnyRouteAllowsMultipleReaders() + { + // arrange + var collection = new QueryRouteCollection(typeof(string)); + + // act + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => null)); + var beforeMultipleReaders = collection.MultipleReaders; + + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => null, true)); + var afterMultipleReaders = collection.MultipleReaders; + + // assert + Assert.That(beforeMultipleReaders, Is.False); + Assert.That(afterMultipleReaders, Is.True); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull() + { + // arrange + var calls = new List(); + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.Null); + Assert.That(calls, Is.EqualTo(new[] { "no-topic" })); + } + + [Test] + public void Handle_Should_ReturnFalse_WhenNoRoutesMatch() + { + // arrange + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("other-topic", MessageRoute.CreateWithTopicFilter("type", "other-topic", (_, _, _, _) => null)); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.False); + Assert.That(result, Is.Null); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes() + { + // arrange + var calls = new List(); + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.Null); + Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" })); + } + + [Test] + public void Handle_Should_StopAfterFirstNonNullMatchingResult_WhenMultipleReadersIsFalse() + { + // arrange + var calls = new List(); + var expectedResult = CallResult.SuccessResult; + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return expectedResult; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return new CallResult(null); + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(expectedResult)); + Assert.That(calls, Is.EqualTo(new[] { "first" })); + } + + [Test] + public void Handle_Should_ContinueAfterNonNullMatchingResult_WhenMultipleReadersIsTrue() + { + // arrange + var calls = new List(); + var expectedResult = CallResult.SuccessResult; + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return expectedResult; + }, true)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return new CallResult(null); + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(expectedResult)); + Assert.That(calls, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void Handle_Should_ContinueUntilNonNullResult_WhenEarlierMatchingRoutesReturnNull() + { + // arrange + var calls = new List(); + var expectedResult = CallResult.SuccessResult; + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return expectedResult; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("third"); + return new CallResult(null); + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(expectedResult)); + Assert.That(calls, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void Handle_Should_ReturnHandledTrue_WhenMatchingRoutesReturnNull() + { + // arrange + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => null)); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.Null); + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs new file mode 100644 index 0000000..ccb6686 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs @@ -0,0 +1,167 @@ +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using CryptoExchange.Net.Sockets.Interfaces; +using NUnit.Framework; +using System; +using System.Linq; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class RoutingTableTests + { + [Test] + public void Update_Should_CreateEntriesPerTypeIdentifier_WithCorrectDeserializationTypeAndHandlers() + { + // arrange + var processor1 = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null))); + + var processor2 = new TestMessageProcessor( + 2, + MessageRouter.Create( + MessageRoute.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null))); + + var table = new RoutingTable(); + + // act + table.Update(new IMessageProcessor[] { processor1, processor2 }); + + var type1Entry = table.GetRouteTableEntry("type1"); + var type2Entry = table.GetRouteTableEntry("type2"); + var missingEntry = table.GetRouteTableEntry("missing"); + + // assert + Assert.That(type1Entry, Is.Not.Null); + Assert.That(type2Entry, Is.Not.Null); + Assert.That(missingEntry, Is.Null); + + Assert.That(type1Entry!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(type1Entry.IsStringOutput, Is.True); + Assert.That(type1Entry.Handlers, Has.Count.EqualTo(1)); + Assert.That(type1Entry.Handlers.Single(), Is.SameAs(processor1)); + + Assert.That(type2Entry!.DeserializationType, Is.EqualTo(typeof(int))); + Assert.That(type2Entry.IsStringOutput, Is.False); + Assert.That(type2Entry.Handlers, Has.Count.EqualTo(1)); + Assert.That(type2Entry.Handlers.Single(), Is.SameAs(processor2)); + } + + [Test] + public void Update_Should_AddMultipleProcessors_ForSameTypeIdentifier() + { + // arrange + var processor1 = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null))); + + var processor2 = new TestMessageProcessor( + 2, + MessageRouter.Create( + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null))); + + var table = new RoutingTable(); + + // act + table.Update(new IMessageProcessor[] { processor1, processor2 }); + var entry = table.GetRouteTableEntry("type1"); + + // assert + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(entry.Handlers, Has.Count.EqualTo(2)); + Assert.That(entry.Handlers, Does.Contain(processor1)); + Assert.That(entry.Handlers, Does.Contain(processor2)); + } + + [Test] + public void Update_Should_ReplacePreviousEntries() + { + // arrange + var initialProcessor = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null))); + + var replacementProcessor = new TestMessageProcessor( + 2, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type2", (_, _, _, _) => null))); + + var table = new RoutingTable(); + table.Update(new IMessageProcessor[] { initialProcessor }); + + // act + table.Update(new IMessageProcessor[] { replacementProcessor }); + + var oldEntry = table.GetRouteTableEntry("type1"); + var newEntry = table.GetRouteTableEntry("type2"); + + // assert + Assert.That(oldEntry, Is.Null); + Assert.That(newEntry, Is.Not.Null); + Assert.That(newEntry!.DeserializationType, Is.EqualTo(typeof(int))); + Assert.That(newEntry.Handlers, Has.Count.EqualTo(1)); + Assert.That(newEntry.Handlers.Single(), Is.SameAs(replacementProcessor)); + } + + [Test] + public void Update_WithEmptyProcessors_Should_ClearEntries() + { + // arrange + var processor = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null))); + + var table = new RoutingTable(); + table.Update(new IMessageProcessor[] { processor }); + + // act + table.Update(Array.Empty()); + + // assert + Assert.That(table.GetRouteTableEntry("type1"), Is.Null); + } + + [Test] + public void TypeRoutingCollection_Should_SetIsStringOutput_BasedOnDeserializationType() + { + // arrange & act + var stringCollection = new TypeRoutingCollection(typeof(string)); + var intCollection = new TypeRoutingCollection(typeof(int)); + + // assert + Assert.That(stringCollection.IsStringOutput, Is.True); + Assert.That(stringCollection.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(stringCollection.Handlers, Is.Empty); + + Assert.That(intCollection.IsStringOutput, Is.False); + Assert.That(intCollection.DeserializationType, Is.EqualTo(typeof(int))); + Assert.That(intCollection.Handlers, Is.Empty); + } + + private sealed class TestMessageProcessor : IMessageProcessor + { + public int Id { get; } + public MessageRouter MessageRouter { get; } + + public TestMessageProcessor(int id, MessageRouter messageRouter) + { + Id = id; + MessageRouter = messageRouter; + } + + public event Action? OnMessageRouterUpdated; + + public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection socketConnection, DateTime receiveTime, string? originalData, object result) + { + return true; + } + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs new file mode 100644 index 0000000..8d68807 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs @@ -0,0 +1,160 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets.Default.Routing; +using NUnit.Framework; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class SubscriptionRouterTests + { + [Test] + public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType() + { + // arrange + var routes = new MessageRoute[] + { + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null) + }; + + var router = new SubscriptionRouter(routes); + + // act + var type1Routes = router.GetRoutes("type1"); + var type2Routes = router.GetRoutes("type2"); + var missingRoutes = router.GetRoutes("missing"); + + // assert + Assert.That(type1Routes, Is.Not.Null); + Assert.That(type2Routes, Is.Not.Null); + Assert.That(missingRoutes, Is.Null); + + Assert.That(type1Routes, Is.TypeOf()); + Assert.That(type2Routes, Is.TypeOf()); + Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int))); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.EqualTo(new[] { "no-topic" })); + } + + [Test] + public void Handle_Should_ReturnFalse_WhenNoRoutesMatch() + { + // arrange + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute("other-topic", MessageRoute.CreateWithTopicFilter("type", "other-topic", (_, _, _, _) => null)); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.False); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" })); + } + + [Test] + public void Handle_Should_InvokeAllMatchingTopicRoutes() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return CallResult.SuccessResult; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void Handle_Should_NotInvokeTopicRoutes_WhenTopicFilterIsNull() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.False); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.Empty); + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs b/CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs deleted file mode 100644 index ea991ee..0000000 --- a/CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs +++ /dev/null @@ -1,490 +0,0 @@ -using CryptoExchange.Net.Attributes; -using CryptoExchange.Net.Converters; -using CryptoExchange.Net.Converters.SystemTextJson; -using CryptoExchange.Net.Objects; -using CryptoExchange.Net.SharedApis; -using CryptoExchange.Net.Testing; -using Newtonsoft.Json.Linq; -using NUnit.Framework; -using System; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CryptoExchange.Net.UnitTests -{ - [TestFixture()] - public class SystemTextJsonConverterTests - { - [TestCase("2021-05-12")] - [TestCase("20210512")] - [TestCase("210512")] - [TestCase("1620777600.000")] - [TestCase("1620777600000")] - [TestCase("2021-05-12T00:00:00.000Z")] - [TestCase("2021-05-12T00:00:00.000000000Z")] - [TestCase("0.000000", true)] - [TestCase("0", true)] - [TestCase("", true)] - [TestCase(" ", true)] - public void TestDateTimeConverterString(string input, bool expectNull = false) - { - var output = JsonSerializer.Deserialize($"{{ \"time\": \"{input}\" }}"); - Assert.That(output.Time == (expectNull ? null: new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); - } - - [TestCase(1620777600.000)] - [TestCase(1620777600000d)] - public void TestDateTimeConverterDouble(double input) - { - var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); - Assert.That(output.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [TestCase(1620777600)] - [TestCase(1620777600000)] - [TestCase(1620777600000000)] - [TestCase(1620777600000000000)] - [TestCase(0, true)] - public void TestDateTimeConverterLong(long input, bool expectNull = false) - { - var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); - Assert.That(output.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); - } - - [TestCase(1620777600)] - [TestCase(1620777600.000)] - public void TestDateTimeConverterFromSeconds(double input) - { - var output = DateTimeConverter.ConvertFromSeconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToSeconds() - { - var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600); - } - - [TestCase(1620777600000)] - [TestCase(1620777600000.000)] - public void TestDateTimeConverterFromMilliseconds(double input) - { - var output = DateTimeConverter.ConvertFromMilliseconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToMilliseconds() - { - var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600000); - } - - [TestCase(1620777600000000)] - public void TestDateTimeConverterFromMicroseconds(long input) - { - var output = DateTimeConverter.ConvertFromMicroseconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToMicroseconds() - { - var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600000000); - } - - [TestCase(1620777600000000000)] - public void TestDateTimeConverterFromNanoseconds(long input) - { - var output = DateTimeConverter.ConvertFromNanoseconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToNanoseconds() - { - var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600000000000); - } - - [TestCase()] - public void TestDateTimeConverterNull() - { - var output = JsonSerializer.Deserialize($"{{ \"time\": null }}"); - Assert.That(output.Time == null); - } - - [TestCase(TestEnum.One, "1")] - [TestCase(TestEnum.Two, "2")] - [TestCase(TestEnum.Three, "three")] - [TestCase(TestEnum.Four, "Four")] - [TestCase(null, null)] - public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected) - { - var output = EnumConverter.GetString(value); - Assert.That(output == expected); - } - - [TestCase(TestEnum.One, "1")] - [TestCase(TestEnum.Two, "2")] - [TestCase(TestEnum.Three, "three")] - [TestCase(TestEnum.Four, "Four")] - public void TestEnumConverterGetStringTests(TestEnum value, string expected) - { - var output = EnumConverter.GetString(value); - Assert.That(output == expected); - } - - [TestCase("1", TestEnum.One)] - [TestCase("2", TestEnum.Two)] - [TestCase("3", TestEnum.Three)] - [TestCase("three", TestEnum.Three)] - [TestCase("Four", TestEnum.Four)] - [TestCase("four", TestEnum.Four)] - [TestCase("Four1", null)] - [TestCase(null, null)] - public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext())); - Assert.That(output.Value == expected); - } - - [TestCase("1", TestEnum.One)] - [TestCase("2", TestEnum.Two)] - [TestCase("3", TestEnum.Three)] - [TestCase("three", TestEnum.Three)] - [TestCase("Four", TestEnum.Four)] - [TestCase("four", TestEnum.Four)] - [TestCase("Four1", (TestEnum)(-9))] - [TestCase(null, (TestEnum)(-9))] - public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}"); - Assert.That(output.Value == expected); - } - - [Test] - public void TestEnumConverterMapsUndefinedValueCorrectlyIfDefaultIsDefined() - { - var output = JsonSerializer.Deserialize($"\"TestUndefined\""); - Assert.That((int)output == -99); - } - - [TestCase("1", TestEnum.One)] - [TestCase("2", TestEnum.Two)] - [TestCase("3", TestEnum.Three)] - [TestCase("three", TestEnum.Three)] - [TestCase("Four", TestEnum.Four)] - [TestCase("four", TestEnum.Four)] - [TestCase("Four1", null)] - [TestCase(null, null)] - public void TestEnumConverterParseStringTests(string value, TestEnum? expected) - { - var result = EnumConverter.ParseString(value); - Assert.That(result == expected); - } - - [Test] - public void TestEnumConverterParseNullOnNonNullableOnlyLogsOnce() - { - LibraryHelpers.StaticLogger = new TraceLogger(); - var listener = new EnumValueTraceListener(); - Trace.Listeners.Add(listener); - EnumConverter.Reset(); - try - { - Assert.Throws(() => - { - var result = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new SerializationContext())); - }); - - Assert.DoesNotThrow(() => - { - var result2 = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new SerializationContext())); - }); - } - finally - { - Trace.Listeners.Remove(listener); - } - } - - [TestCase("1", true)] - [TestCase("true", true)] - [TestCase("yes", true)] - [TestCase("y", true)] - [TestCase("on", true)] - [TestCase("-1", false)] - [TestCase("0", false)] - [TestCase("n", false)] - [TestCase("no", false)] - [TestCase("false", false)] - [TestCase("off", false)] - [TestCase("", null)] - public void TestBoolConverter(string value, bool? expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext())); - Assert.That(output.Value == expected); - } - - [TestCase("1", true)] - [TestCase("true", true)] - [TestCase("yes", true)] - [TestCase("y", true)] - [TestCase("on", true)] - [TestCase("-1", false)] - [TestCase("0", false)] - [TestCase("n", false)] - [TestCase("no", false)] - [TestCase("false", false)] - [TestCase("off", false)] - [TestCase("", false)] - public void TestBoolConverterNotNullable(string value, bool expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext())); - Assert.That(output.Value == expected); - } - - [TestCase("1", 1)] - [TestCase("1.1", 1.1)] - [TestCase("-1.1", -1.1)] - [TestCase(null, null)] - [TestCase("", null)] - [TestCase("null", null)] - [TestCase("nan", null)] - [TestCase("1E+2", 100)] - [TestCase("1E-2", 0.01)] - [TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue - [TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue - [TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue - [TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue - public void TestDecimalConverterString(string value, decimal? expected) - { - var result = JsonSerializer.Deserialize("{ \"test\": \""+ value + "\"}"); - Assert.That(result.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue: expected)); - } - - [TestCase("1", 1)] - [TestCase("1.1", 1.1)] - [TestCase("-1.1", -1.1)] - [TestCase("null", null)] - [TestCase("1E+2", 100)] - [TestCase("1E-2", 0.01)] - [TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue - public void TestDecimalConverterNumber(string value, decimal? expected) - { - var result = JsonSerializer.Deserialize("{ \"test\": " + value + "}"); - Assert.That(result.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected)); - } - - [Test()] - public void TestArrayConverter() - { - var data = new Test() - { - Prop1 = 2, - Prop2 = null, - Prop3 = "123", - Prop3Again = "123", - Prop4 = null, - Prop5 = new Test2 - { - Prop21 = 3, - Prop22 = "456" - }, - Prop6 = new Test3 - { - Prop31 = 4, - Prop32 = "789" - }, - Prop7 = TestEnum.Two, - TestInternal = new Test - { - Prop1 = 10 - }, - Prop8 = new Test3 - { - Prop31 = 5, - Prop32 = "101" - }, - }; - - var options = new JsonSerializerOptions() - { - TypeInfoResolver = new SerializationContext() - }; - var serialized = JsonSerializer.Serialize(data); - var deserialized = JsonSerializer.Deserialize(serialized); - - Assert.That(deserialized.Prop1, Is.EqualTo(2)); - Assert.That(deserialized.Prop2, Is.Null); - Assert.That(deserialized.Prop3, Is.EqualTo("123")); - Assert.That(deserialized.Prop3Again, Is.EqualTo("123")); - Assert.That(deserialized.Prop4, Is.Null); - Assert.That(deserialized.Prop5.Prop21, Is.EqualTo(3)); - Assert.That(deserialized.Prop5.Prop22, Is.EqualTo("456")); - Assert.That(deserialized.Prop6.Prop31, Is.EqualTo(4)); - Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789")); - Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two)); - Assert.That(deserialized.TestInternal.Prop1, Is.EqualTo(10)); - Assert.That(deserialized.Prop8.Prop31, Is.EqualTo(5)); - Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101")); - } - - [TestCase(TradingMode.Spot, "ETH", "USDT", null)] - [TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)] - [TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)] - public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime) - { - DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value); - var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time); - - var serialized = JsonSerializer.Serialize(symbol); - var restored = JsonSerializer.Deserialize(serialized); - - Assert.That(restored.TradingMode, Is.EqualTo(symbol.TradingMode)); - Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset)); - Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset)); - Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime)); - } - - [TestCase(0.1, null, null)] - [TestCase(0.1, 0.1, null)] - [TestCase(0.1, 0.1, 0.1)] - [TestCase(null, 0.1, null)] - [TestCase(null, 0.1, 0.1)] - public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity) - { - var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity); - - var serialized = JsonSerializer.Serialize(symbol); - var restored = JsonSerializer.Deserialize(serialized); - - Assert.That(restored.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset)); - Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset)); - Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts)); - } - } - - public class STJDecimalObject - { - [JsonConverter(typeof(DecimalConverter))] - [JsonPropertyName("test")] - public decimal? Test { get; set; } - } - - public class STJTimeObject - { - [JsonConverter(typeof(DateTimeConverter))] - [JsonPropertyName("time")] - public DateTime? Time { get; set; } - } - - public class STJEnumObject - { - public TestEnum? Value { get; set; } - } - - public class NotNullableSTJEnumObject - { - public TestEnum Value { get; set; } - } - - public class STJBoolObject - { - public bool? Value { get; set; } - } - - public class NotNullableSTJBoolObject - { - public bool Value { get; set; } - } - - [JsonConverter(typeof(ArrayConverter))] - record Test - { - [ArrayProperty(0)] - public int Prop1 { get; set; } - [ArrayProperty(1)] - public int? Prop2 { get; set; } - [ArrayProperty(2)] - public string Prop3 { get; set; } - [ArrayProperty(2)] - public string Prop3Again { get; set; } - [ArrayProperty(3)] - public string Prop4 { get; set; } - [ArrayProperty(4)] - public Test2 Prop5 { get; set; } - [ArrayProperty(5)] - public Test3 Prop6 { get; set; } - [ArrayProperty(6), JsonConverter(typeof(EnumConverter))] - public TestEnum? Prop7 { get; set; } - [ArrayProperty(7)] - public Test TestInternal { get; set; } - [ArrayProperty(8), JsonConversion] - public Test3 Prop8 { get; set; } - } - - [JsonConverter(typeof(ArrayConverter))] - record Test2 - { - [ArrayProperty(0)] - public int Prop21 { get; set; } - [ArrayProperty(1)] - public string Prop22 { get; set; } - } - - record Test3 - { - [JsonPropertyName("prop31")] - public int Prop31 { get; set; } - [JsonPropertyName("prop32")] - public string Prop32 { get; set; } - } - - [JsonConverter(typeof(EnumConverter))] - public enum TestEnum - { - [Map("1")] - One, - [Map("2")] - Two, - [Map("three", "3")] - Three, - Four - } - - [JsonConverter(typeof(EnumConverter))] - public enum TestEnum2 - { - [Map("-9")] - Minus9 = -9, - [Map("1")] - One, - [Map("2")] - Two, - [Map("three", "3")] - Three, - Four - } - - [JsonSerializable(typeof(Test))] - [JsonSerializable(typeof(Test2))] - [JsonSerializable(typeof(Test3))] - [JsonSerializable(typeof(NotNullableSTJBoolObject))] - [JsonSerializable(typeof(STJBoolObject))] - [JsonSerializable(typeof(NotNullableSTJEnumObject))] - [JsonSerializable(typeof(STJEnumObject))] - [JsonSerializable(typeof(STJDecimalObject))] - [JsonSerializable(typeof(STJTimeObject))] - internal partial class SerializationContext : JsonSerializerContext - { - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs deleted file mode 100644 index e7a6496..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using CryptoExchange.Net.Authentication; -using CryptoExchange.Net.Clients; -using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; -using CryptoExchange.Net.Converters.SystemTextJson; -using CryptoExchange.Net.Interfaces; -using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Objects.Errors; -using CryptoExchange.Net.Objects.Options; -using CryptoExchange.Net.SharedApis; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace CryptoExchange.Net.UnitTests -{ - public class TestBaseClient: BaseClient - { - public TestSubClient SubClient { get; } - - public TestBaseClient(): base(null, "Test") - { - var options = new TestClientOptions(); - _logger = NullLogger.Instance; - Initialize(options); - SubClient = AddApiClient(new TestSubClient(options, new RestApiOptions())); - } - - public TestBaseClient(TestClientOptions exchangeOptions) : base(null, "Test") - { - _logger = NullLogger.Instance; - Initialize(exchangeOptions); - SubClient = AddApiClient(new TestSubClient(exchangeOptions, new RestApiOptions())); - } - - public void Log(LogLevel verbosity, string data) - { - _logger.Log(verbosity, data); - } - } - - public class TestSubClient : RestApiClient - { - protected override IRestMessageHandler MessageHandler => throw new NotImplementedException(); - - public TestSubClient(RestExchangeOptions options, RestApiOptions apiOptions) : base(new TraceLogger(), null, "https://localhost:123", options, apiOptions) - { - } - - public CallResult Deserialize(string data) - { - return new CallResult(JsonSerializer.Deserialize(data)); - } - - /// - public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; - protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions()); - protected override TestAuthProvider CreateAuthenticationProvider(HMACCredential credentials) => throw new NotImplementedException(); - protected override Task> GetServerTimestampAsync() => throw new NotImplementedException(); - } - - public class TestAuthProvider : AuthenticationProvider - { - public TestAuthProvider(HMACCredential credentials) : base(credentials, credentials) - { - } - - public override void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig) - { - } - - public string GetKey() => Credential.Key; - public string GetSecret() => Credential.Secret; - } - - public class TestEnvironment : TradeEnvironment - { - public string TestAddress { get; } - - public TestEnvironment(string name, string url) : base(name) - { - TestAddress = url; - } - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs deleted file mode 100644 index 9e95043..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace CryptoExchange.Net.UnitTests.TestImplementations -{ - public class TestHelpers - { - [ExcludeFromCodeCoverage] - public static bool AreEqual(T self, T to, params string[] ignore) where T : class - { - if (self != null && to != null) - { - var type = self.GetType(); - var ignoreList = new List(ignore); - foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (ignoreList.Contains(pi.Name)) - { - continue; - } - - var selfValue = type.GetProperty(pi.Name).GetValue(self, null); - var toValue = type.GetProperty(pi.Name).GetValue(to, null); - - if (pi.PropertyType.IsClass && !pi.PropertyType.Module.ScopeName.Equals("System.Private.CoreLib.dll")) - { - // Check of "CommonLanguageRuntimeLibrary" is needed because string is also a class - if (AreEqual(selfValue, toValue, ignore)) - { - continue; - } - - return false; - } - - if (selfValue != toValue && (selfValue == null || !selfValue.Equals(toValue))) - { - return false; - } - } - - return true; - } - - return self == to; - } - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs deleted file mode 100644 index 487e825..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs +++ /dev/null @@ -1,213 +0,0 @@ -using CryptoExchange.Net.Interfaces; -using CryptoExchange.Net.Objects; -using Moq; -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using CryptoExchange.Net.Authentication; -using System.Collections.Generic; -using Microsoft.Extensions.Logging; -using CryptoExchange.Net.Clients; -using Microsoft.Extensions.Options; -using System.Linq; -using CryptoExchange.Net.Converters.SystemTextJson; -using System.Text.Json.Serialization; -using System.Net.Http.Headers; -using CryptoExchange.Net.SharedApis; -using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; - -namespace CryptoExchange.Net.UnitTests.TestImplementations -{ - public class TestRestClient: BaseRestClient - { - public TestRestApi1Client Api1 { get; } - public TestRestApi2Client Api2 { get; } - - public TestRestClient(Action optionsDelegate = null) - : this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate))) - { - } - - public TestRestClient(HttpClient httpClient, ILoggerFactory loggerFactory, IOptions options) : base(loggerFactory, "Test") - { - Initialize(options.Value); - - Api1 = AddApiClient(new TestRestApi1Client(options.Value)); - Api2 = AddApiClient(new TestRestApi2Client(options.Value)); - } - - public void SetResponse(string responseData, out IRequest requestObj) - { - var expectedBytes = Encoding.UTF8.GetBytes(responseData); - var responseStream = new MemoryStream(); - responseStream.Write(expectedBytes, 0, expectedBytes.Length); - responseStream.Seek(0, SeekOrigin.Begin); - - var response = new Mock(); - response.Setup(c => c.IsSuccessStatusCode).Returns(true); - response.Setup(c => c.GetResponseStreamAsync(It.IsAny())).Returns(Task.FromResult((Stream)responseStream)); - - var headers = new HttpRequestMessage().Headers; - var request = new Mock(); - request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com")); - request.Setup(c => c.GetResponseAsync(It.IsAny())).Returns(Task.FromResult(response.Object)); - request.Setup(c => c.SetContent(It.IsAny(), It.IsAny(), It.IsAny())).Callback(new Action((content, encoding, type) => { request.Setup(r => r.Content).Returns(content); })); - request.Setup(c => c.AddHeader(It.IsAny(), It.IsAny())).Callback((key, val) => headers.Add(key, new string[] { val })); - request.Setup(c => c.GetHeaders()).Returns(() => headers); - - var factory = Mock.Get(Api1.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => - { - request.Setup(a => a.Uri).Returns(uri); - request.Setup(a => a.Method).Returns(method); - }) - .Returns(request.Object); - - factory = Mock.Get(Api2.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => - { - request.Setup(a => a.Uri).Returns(uri); - request.Setup(a => a.Method).Returns(method); - }) - .Returns(request.Object); - requestObj = request.Object; - } - - public void SetErrorWithoutResponse(HttpStatusCode code, string message) - { - var we = new HttpRequestException(); - typeof(HttpRequestException).GetField("_message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(we, message); - - var request = new Mock(); - request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com")); - request.Setup(c => c.GetHeaders()).Returns(new HttpRequestMessage().Headers); - request.Setup(c => c.GetResponseAsync(It.IsAny())).Throws(we); - - var factory = Mock.Get(Api1.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(request.Object); - - - factory = Mock.Get(Api2.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(request.Object); - } - - public void SetErrorWithResponse(string responseData, HttpStatusCode code) - { - var expectedBytes = Encoding.UTF8.GetBytes(responseData); - var responseStream = new MemoryStream(); - responseStream.Write(expectedBytes, 0, expectedBytes.Length); - responseStream.Seek(0, SeekOrigin.Begin); - - var response = new Mock(); - response.Setup(c => c.IsSuccessStatusCode).Returns(false); - response.Setup(c => c.GetResponseStreamAsync(It.IsAny())).Returns(Task.FromResult((Stream)responseStream)); - - var headers = new List>(); - var request = new Mock(); - request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com")); - request.Setup(c => c.GetResponseAsync(It.IsAny())).Returns(Task.FromResult(response.Object)); - request.Setup(c => c.AddHeader(It.IsAny(), It.IsAny())).Callback((key, val) => headers.Add(new KeyValuePair(key, new string[] { val }))); - request.Setup(c => c.GetHeaders()).Returns(new HttpRequestMessage().Headers); - - var factory = Mock.Get(Api1.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => request.Setup(a => a.Uri).Returns(uri)) - .Returns(request.Object); - - factory = Mock.Get(Api2.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => request.Setup(a => a.Uri).Returns(uri)) - .Returns(request.Object); - } - } - - public class TestRestApi1Client : RestApiClient - { - protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler(); - - public TestRestApi1Client(TestClientOptions options) : base(new TraceLogger(), null, "https://localhost:123", options, options.Api1Options) - { - RequestFactory = new Mock().Object; - } - - /// - public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; - - protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions()); - - public async Task> Request(CancellationToken ct = default) where T : class - { - return await SendAsync("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct); - } - - public async Task> RequestWithParams(HttpMethod method, ParameterCollection parameters, Dictionary headers) where T : class - { - return await SendAsync("http://www.test.com", new RequestDefinition("/", method) { Weight = 0 }, parameters, default, additionalHeaders: headers); - } - - public void SetParameterPosition(HttpMethod method, HttpMethodParameterPosition position) - { - ParameterPositions[method] = position; - } - - protected override TestAuthProvider CreateAuthenticationProvider(HMACCredential credentials) - => new TestAuthProvider(credentials); - - protected override Task> GetServerTimestampAsync() - { - throw new NotImplementedException(); - } - } - - public class TestRestApi2Client : RestApiClient - { - protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler(); - - public TestRestApi2Client(TestClientOptions options) : base(new TraceLogger(), null, "https://localhost:123", options, options.Api2Options) - { - RequestFactory = new Mock().Object; - } - - protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions()); - - /// - public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; - - public async Task> Request(CancellationToken ct = default) where T : class - { - return await SendAsync("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct); - } - - protected override TestAuthProvider CreateAuthenticationProvider(HMACCredential credentials) - => new TestAuthProvider(credentials); - - protected override Task> GetServerTimestampAsync() - { - throw new NotImplementedException(); - } - - } - - public class TestError - { - [JsonPropertyName("errorCode")] - public int ErrorCode { get; set; } - [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } - } - - public class ParseErrorTestRestClient: TestRestClient - { - public ParseErrorTestRestClient() { } - - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs deleted file mode 100644 index bf4be5b..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; -using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers; -using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Objects.Errors; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace CryptoExchange.Net.UnitTests.TestImplementations -{ - internal class TestRestMessageHandler : JsonRestMessageHandler - { - private ErrorMapping _errorMapping = new ErrorMapping([]); - public override JsonSerializerOptions Options => new JsonSerializerOptions(); - - public override async ValueTask ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream) - { - var result = await GetJsonDocument(responseStream).ConfigureAwait(false); - if (result.Item1 != null) - return result.Item1; - - var errorData = result.Item2.Deserialize(); - return new ServerError(errorData.ErrorCode, _errorMapping.GetErrorInfo(errorData.ErrorCode.ToString(), errorData.ErrorMessage)); - } - } -} diff --git a/CryptoExchange.Net.UnitTests/TestSerializerContext.cs b/CryptoExchange.Net.UnitTests/TestSerializerContext.cs deleted file mode 100644 index ce23994..0000000 --- a/CryptoExchange.Net.UnitTests/TestSerializerContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CryptoExchange.Net.UnitTests.TestImplementations; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace CryptoExchange.Net.UnitTests -{ - [JsonSerializable(typeof(string))] - [JsonSerializable(typeof(int))] - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(TestObject))] - internal partial class TestSerializerContext : JsonSerializerContext - { - } -} diff --git a/CryptoExchange.Net.UnitTests/UriSerializationTests.cs b/CryptoExchange.Net.UnitTests/UriSerializationTests.cs new file mode 100644 index 0000000..8ff70d4 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/UriSerializationTests.cs @@ -0,0 +1,105 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; +using CryptoExchange.Net; +using CryptoExchange.Net.Objects; + +namespace CryptoExchange.Net.UnitTests +{ + internal class UriSerializationTests + { + [Test] + public void CreateParamString_SerializesBasicValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", "1" }, + { "b", 2 }, + { "c", true } + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array); + + Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True")); + } + + [Test] + public void CreateParamString_SerializesArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1", "2" } }, + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array); + + Assert.That(parameterString, Is.EqualTo("a[]=1&a[]=2")); + } + + [Test] + public void CreateParamStringEncoded_SerializesArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1+2", "2+3" } }, + }; + + var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.Array); + + Assert.That(parameterString, Is.EqualTo("a[]=1%2B2&a[]=2%2B3")); + } + + [Test] + public void CreateParamString_SerializesJsonArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1", "2" } }, + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.JsonArray); + + Assert.That(parameterString, Is.EqualTo("a=[1,2]")); + } + + [Test] + public void CreateParamStringEncoded_SerializesJsonArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1+2", "2+3" } }, + }; + + var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.JsonArray); + + Assert.That(parameterString, Is.EqualTo("a=[1%2B2,2%2B3]")); + } + + [Test] + public void CreateParamString_SerializesMultipleValuesArrayCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1", "2" } }, + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.MultipleValues); + + Assert.That(parameterString, Is.EqualTo("a=1&a=2")); + } + + [Test] + public void CreateParamStringEncoded_SerializesMultipleValuesArrayCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1+2", "2+3" } }, + }; + + var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.MultipleValues); + + Assert.That(parameterString, Is.EqualTo("a=1%2B2&a=2%2B3")); + } + } +} diff --git a/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs b/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs index 4586a7a..fa6ea5d 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs @@ -82,14 +82,23 @@ namespace CryptoExchange.Net.Converters.SystemTextJson #if NET8_0_OR_GREATER private static FrozenSet? _mappingToEnum = null; private static FrozenDictionary? _mappingToString = null; + + private static bool RunOptimistic => true; #else private static List? _mappingToEnum = null; private static Dictionary? _mappingToString = null; + + // In NetStandard the `ValueTextEquals` method used is slower than just string comparing + // so only bother in newer frameworks + private static bool RunOptimistic => false; #endif private NullableEnumConverter? _nullableEnumConverter = null; + private static Type _enumType = typeof(T); private static T? _undefinedEnumValue; + private static bool _hasFlagsAttribute = _enumType.IsDefined(typeof(FlagsAttribute)); private static ConcurrentBag _unknownValuesWarned = new ConcurrentBag(); + private static ConcurrentBag _notOptimalValuesWarned = new ConcurrentBag(); internal class NullableEnumConverter : JsonConverter { @@ -153,10 +162,17 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private T? ReadNullable(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, out bool isEmptyStringOrNull) { isEmptyStringOrNull = false; - var enumType = typeof(T); if (_mappingToEnum == null) CreateMapping(); + if (RunOptimistic) + { + var resultOptimistic = GetValueOptimistic(ref reader); + if (resultOptimistic != null) + return resultOptimistic.Value; + } + + var isNumber = reader.TokenType == JsonTokenType.Number; var stringValue = reader.TokenType switch { JsonTokenType.String => reader.GetString(), @@ -173,8 +189,9 @@ namespace CryptoExchange.Net.Converters.SystemTextJson return null; } - if (!GetValue(enumType, stringValue, out var result)) + if (!GetValue(stringValue, out var result)) { + // Note: checking this here and before the GetValue seems redundant but it allows enum mapping for empty strings if (string.IsNullOrWhiteSpace(stringValue)) { isEmptyStringOrNull = true; @@ -185,13 +202,22 @@ namespace CryptoExchange.Net.Converters.SystemTextJson if (!_unknownValuesWarned.Contains(stringValue)) { _unknownValuesWarned.Add(stringValue!); - LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should added please open an issue on the Github repo"); + LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should be added please open an issue on the Github repo"); } } return null; } + if (RunOptimistic && !isNumber) + { + if (!_notOptimalValuesWarned.Contains(stringValue)) + { + _notOptimalValuesWarned.Add(stringValue!); + LibraryHelpers.StaticLogger?.LogTrace($"Enum mapping sub-optimal. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]"); + } + } + return result; } @@ -202,18 +228,40 @@ namespace CryptoExchange.Net.Converters.SystemTextJson writer.WriteStringValue(stringValue); } - private static bool GetValue(Type objectType, string value, out T? result) + /// + /// Try to get the enum value based on the string value using the Utf8JsonReader's ValueTextEquals method. + /// This is an optimization to avoid string allocations when possible, but can only match case sensitively + /// + private static T? GetValueOptimistic(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.String) + return null; + + foreach (var item in _mappingToEnum!) + { + if (reader.ValueTextEquals(item.StringValue)) + return item.Value; + } + + return null; + } + + private static bool GetValue(string value, out T? result) { if (_mappingToEnum != null) { EnumMapping? mapping = null; - // Try match on full equals - foreach (var item in _mappingToEnum) + // If we tried the optimistic path first we already know its not case match + if (!RunOptimistic) { - if (item.StringValue.Equals(value, StringComparison.Ordinal)) + // Try match on full equals + foreach (var item in _mappingToEnum) { - mapping = item; - break; + if (item.StringValue.Equals(value, StringComparison.Ordinal)) + { + mapping = item; + break; + } } } @@ -237,10 +285,10 @@ namespace CryptoExchange.Net.Converters.SystemTextJson } } - if (objectType.IsDefined(typeof(FlagsAttribute))) + if (_hasFlagsAttribute) { var intValue = int.Parse(value); - result = (T)Enum.ToObject(objectType, intValue); + result = (T)Enum.ToObject(_enumType, intValue); return true; } @@ -262,8 +310,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson try { // If no explicit mapping is found try to parse string - result = (T)Enum.Parse(objectType, value, true); - if (!Enum.IsDefined(objectType, result)) +#if NET8_0_OR_GREATER + result = Enum.Parse(value, true); +#else + result = (T)Enum.Parse(_enumType, value, true); +#endif + if (!Enum.IsDefined(_enumType, result)) { result = default; return false; @@ -280,11 +332,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private static void CreateMapping() { - var mappingToEnum = new List(); - var mappingToString = new Dictionary(); + var mappingStringToEnum = new List(); + var mappingEnumToString = new Dictionary(); - var enumType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - var enumMembers = enumType.GetFields(); +#pragma warning disable IL2080 + var enumMembers = _enumType.GetFields(); +#pragma warning restore IL2080 foreach (var member in enumMembers) { var maps = member.GetCustomAttributes(typeof(MapAttribute), false); @@ -292,23 +345,29 @@ namespace CryptoExchange.Net.Converters.SystemTextJson { foreach (var value in attribute.Values) { - var enumVal = (T)Enum.Parse(enumType, member.Name); - mappingToEnum.Add(new EnumMapping(enumVal, value)); - if (!mappingToString.ContainsKey(enumVal)) - mappingToString.Add(enumVal, value); +#if NET8_0_OR_GREATER + var enumVal = Enum.Parse(member.Name); +#else + var enumVal = (T)Enum.Parse(_enumType, member.Name); +#endif + + mappingStringToEnum.Add(new EnumMapping(enumVal, value)); + if (!mappingEnumToString.ContainsKey(enumVal)) + mappingEnumToString.Add(enumVal, value); } } } #if NET8_0_OR_GREATER - _mappingToEnum = mappingToEnum.ToFrozenSet(); - _mappingToString = mappingToString.ToFrozenDictionary(); + _mappingToEnum = mappingStringToEnum.ToFrozenSet(); + _mappingToString = mappingEnumToString.ToFrozenDictionary(); #else - _mappingToEnum = mappingToEnum; - _mappingToString = mappingToString; + _mappingToEnum = mappingStringToEnum; + _mappingToString = mappingEnumToString; #endif } + // For testing purposes only, allows resetting the static mapping and warnings internal static void Reset() { _undefinedEnumValue = null; @@ -336,7 +395,6 @@ namespace CryptoExchange.Net.Converters.SystemTextJson /// public static T? ParseString(string value) { - var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); if (_mappingToEnum == null) CreateMapping(); @@ -369,8 +427,11 @@ namespace CryptoExchange.Net.Converters.SystemTextJson try { - // If no explicit mapping is found try to parse string - return (T)Enum.Parse(type, value, true); +#if NET8_0_OR_GREATER + return Enum.Parse(value, true); +#else + return (T)Enum.Parse(_enumType, value, true); +#endif } catch (Exception) { diff --git a/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs b/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs index 53a77ee..2789c23 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs @@ -24,7 +24,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson if (string.IsNullOrEmpty(value)) return default; - return (T?)JsonDocument.Parse(value!).Deserialize(typeof(T), options); + return JsonDocument.Parse(value!).Deserialize(options); } /// diff --git a/CryptoExchange.Net/ExtensionMethods.cs b/CryptoExchange.Net/ExtensionMethods.cs index c8d1693..452dce8 100644 --- a/CryptoExchange.Net/ExtensionMethods.cs +++ b/CryptoExchange.Net/ExtensionMethods.cs @@ -107,7 +107,7 @@ namespace CryptoExchange.Net } else { - uriString.Append('['); + uriString.Append($"{parameter.Key}=["); var firstArrayEntry = true; foreach (var entry in (Array)parameter.Value) { diff --git a/CryptoExchange.Net/Objects/ParameterCollection.cs b/CryptoExchange.Net/Objects/ParameterCollection.cs index d49f8fa..723fb6f 100644 --- a/CryptoExchange.Net/Objects/ParameterCollection.cs +++ b/CryptoExchange.Net/Objects/ParameterCollection.cs @@ -278,6 +278,35 @@ namespace CryptoExchange.Net.Objects base.Add(key, string.Join(",", values)); } + /// + /// Add key as comma separated values + /// +#if NET5_0_OR_GREATER + public void AddCommaSeparated<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicFields)] T>(string key, IEnumerable values) +#else + public void AddCommaSeparated(string key, IEnumerable values) +#endif + where T : struct, Enum + { + base.Add(key, string.Join(",", values.Select(x => EnumConverter.GetString(x)))); + } + + /// + /// Add key as comma separated values if there are values provided + /// +#if NET5_0_OR_GREATER + public void AddOptionalCommaSeparated<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicFields)] T>(string key, IEnumerable? values) +#else + public void AddOptionalCommaSeparated(string key, IEnumerable? values) +#endif + where T : struct, Enum + { + if (values == null || !values.Any()) + return; + + base.Add(key, string.Join(",", values.Select(x => EnumConverter.GetString(x)))); + } + /// /// Add key as boolean lower case value /// diff --git a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs index 074c6a0..9abcea7 100644 --- a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs +++ b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs @@ -45,7 +45,7 @@ namespace CryptoExchange.Net.SharedApis public override Error? ValidateRequest(string exchange, GetOrderBookRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes) { if (request.Limit == null) - return null; + return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes); if (MaxLimit.HasValue && request.Limit.Value > MaxLimit) return ArgumentError.Invalid(nameof(GetOrderBookRequest.Limit), $"Max limit is {MaxLimit}"); diff --git a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs index 815aa29..ef32f6b 100644 --- a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs +++ b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs @@ -22,8 +22,12 @@ namespace CryptoExchange.Net.SharedApis } /// - public Error? Validate(GetRecentTradesRequest request) + public override Error? ValidateRequest(string exchange, GetRecentTradesRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes) { + var baseError = base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes); + if (baseError != null) + return baseError; + if (request.Limit > MaxLimit) return ArgumentError.Invalid(nameof(GetRecentTradesRequest.Limit), $"Only the most recent {MaxLimit} trades are available"); diff --git a/CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs b/CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs new file mode 100644 index 0000000..9ed234e --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs @@ -0,0 +1,104 @@ +using CryptoExchange.Net.Objects; +using System; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + /// + /// Message route + /// + public abstract class MessageRoute + { + /// + /// Type identifier + /// + public string TypeIdentifier { get; set; } + /// + /// Optional topic filter + /// + public string? TopicFilter { get; set; } + + /// + /// Whether responses to this route might be read by multiple listeners + /// + public bool MultipleReaders { get; set; } = false; + + /// + /// Deserialization type + /// + public abstract Type DeserializationType { get; } + + /// + /// ctor + /// + public MessageRoute(string typeIdentifier, string? topicFilter) + { + TypeIdentifier = typeIdentifier; + TopicFilter = topicFilter; + } + + /// + /// Message handler + /// + public abstract CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data); + } + + /// + /// Message route + /// + public class MessageRoute : MessageRoute + { + private Func _handler; + + /// + public override Type DeserializationType { get; } = typeof(TMessage); + + /// + /// ctor + /// + internal MessageRoute(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) + : base(typeIdentifier, topicFilter) + { + _handler = handler; + MultipleReaders = multipleReaders; + } + + /// + /// Create route without topic filter + /// + public static MessageRoute CreateWithoutTopicFilter(string typeIdentifier, Func handler, bool multipleReaders = false) + { + return new MessageRoute(typeIdentifier, null, handler) + { + MultipleReaders = multipleReaders + }; + } + + /// + /// Create route with optional topic filter + /// + public static MessageRoute CreateWithOptionalTopicFilter(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) + { + return new MessageRoute(typeIdentifier, topicFilter, handler) + { + MultipleReaders = multipleReaders + }; + } + + /// + /// Create route with topic filter + /// + public static MessageRoute CreateWithTopicFilter(string typeIdentifier, string topicFilter, Func handler, bool multipleReaders = false) + { + return new MessageRoute(typeIdentifier, topicFilter, handler) + { + MultipleReaders = multipleReaders + }; + } + + /// + public override CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data) + { + return _handler(connection, receiveTime, originalData, (TMessage)data); + } + } +} diff --git a/CryptoExchange.Net/Sockets/MessageRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs similarity index 67% rename from CryptoExchange.Net/Sockets/MessageRouter.cs rename to CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs index 4c29763..4a79db6 100644 --- a/CryptoExchange.Net/Sockets/MessageRouter.cs +++ b/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs @@ -1,16 +1,17 @@ using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Sockets.Default; using System; using System.Collections.Generic; using System.Linq; -namespace CryptoExchange.Net.Sockets +namespace CryptoExchange.Net.Sockets.Default.Routing { /// /// Message router /// public class MessageRouter { + private ProcessorRouter? _routingTable; + /// /// The routes registered for this router /// @@ -24,12 +25,40 @@ namespace CryptoExchange.Net.Sockets Routes = routes; } + /// + /// Build the route mapping + /// + public void BuildQueryRouter() + { + _routingTable = new QueryRouter(Routes); + } + + /// + /// Build the route mapping + /// + public void BuildSubscriptionRouter() + { + _routingTable = new SubscriptionRouter(Routes); + } + + /// + /// Handle message + /// + public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) + { + var routeCollection = (_routingTable ?? throw new NullReferenceException("Routing table not build before handling")).GetRoutes(typeIdentifier); + if (routeCollection == null) + throw new InvalidOperationException($"No routes for {typeIdentifier} message type"); + + return routeCollection.Handle(topicFilter, connection, receiveTime, originalData, data, out result); + } + /// /// Create message router without specific message handler /// public static MessageRouter CreateWithoutHandler(string typeIdentifier, bool multipleReaders = false) { - return new MessageRouter(new MessageRoute(typeIdentifier, (string?)null, (con, receiveTime, originalData, msg) => new CallResult(default, null, null), multipleReaders)); + return new MessageRouter(new MessageRoute(typeIdentifier, null, (con, receiveTime, originalData, msg) => new CallResult(default, null, null), multipleReaders)); } /// @@ -165,104 +194,4 @@ namespace CryptoExchange.Net.Sockets /// public bool ContainsCheck(MessageRoute route) => Routes.Any(x => x.TypeIdentifier == route.TypeIdentifier && x.TopicFilter == route.TopicFilter); } - - /// - /// Message route - /// - public abstract class MessageRoute - { - /// - /// Type identifier - /// - public string TypeIdentifier { get; set; } - /// - /// Optional topic filter - /// - public string? TopicFilter { get; set; } - - /// - /// Whether responses to this route might be read by multiple listeners - /// - public bool MultipleReaders { get; set; } = false; - - /// - /// Deserialization type - /// - public abstract Type DeserializationType { get; } - - /// - /// ctor - /// - public MessageRoute(string typeIdentifier, string? topicFilter) - { - TypeIdentifier = typeIdentifier; - TopicFilter = topicFilter; - } - - /// - /// Message handler - /// - public abstract CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data); - } - - /// - /// Message route - /// - public class MessageRoute : MessageRoute - { - private Func _handler; - - /// - public override Type DeserializationType { get; } = typeof(TMessage); - - /// - /// ctor - /// - internal MessageRoute(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) - : base(typeIdentifier, topicFilter) - { - _handler = handler; - MultipleReaders = multipleReaders; - } - - /// - /// Create route without topic filter - /// - public static MessageRoute CreateWithoutTopicFilter(string typeIdentifier, Func handler, bool multipleReaders = false) - { - return new MessageRoute(typeIdentifier, null, handler) - { - MultipleReaders = multipleReaders - }; - } - - /// - /// Create route with optional topic filter - /// - public static MessageRoute CreateWithOptionalTopicFilter(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) - { - return new MessageRoute(typeIdentifier, topicFilter, handler) - { - MultipleReaders = multipleReaders - }; - } - - /// - /// Create route with topic filter - /// - public static MessageRoute CreateWithTopicFilter(string typeIdentifier, string topicFilter, Func handler, bool multipleReaders = false) - { - return new MessageRoute(typeIdentifier, topicFilter, handler) - { - MultipleReaders = multipleReaders - }; - } - - /// - public override CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data) - { - return _handler(connection, receiveTime, originalData, (TMessage)data); - } - } - } diff --git a/CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs new file mode 100644 index 0000000..6e7c37c --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal abstract class ProcessorRouter + { + public abstract RouteCollection? GetRoutes(string identifier); + } + + internal abstract class ProcessorRouter : ProcessorRouter + where T : RouteCollection + { +#if NET8_0_OR_GREATER + private FrozenDictionary _routeMap; +#else + private Dictionary _routeMap; +#endif + + public ProcessorRouter(IEnumerable routes) + { + var map = BuildFromRoutes(routes); +#if NET8_0_OR_GREATER + _routeMap = map.ToFrozenDictionary(); +#else + _routeMap = map; +#endif + } + + public abstract Dictionary BuildFromRoutes(IEnumerable routes); + + public override RouteCollection? GetRoutes(string identifier) => _routeMap.TryGetValue(identifier, out var routes) ? routes : null; + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs new file mode 100644 index 0000000..82f12d3 --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs @@ -0,0 +1,90 @@ +using CryptoExchange.Net.Objects; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal class QueryRouter : ProcessorRouter + { + public QueryRouter(IEnumerable routes) : base(routes) + { + } + + public override Dictionary BuildFromRoutes(IEnumerable routes) + { + var newMap = new Dictionary(); + foreach (var route in routes) + { + if (!newMap.TryGetValue(route.TypeIdentifier, out var typeMap)) + { + typeMap = new QueryRouteCollection(route.DeserializationType); + newMap.Add(route.TypeIdentifier, typeMap); + } + + typeMap.AddRoute(route.TopicFilter, route); + } + + foreach (var subEntry in newMap.Values) + subEntry.Build(); + + return newMap; + } + } + + internal class QueryRouteCollection : RouteCollection + { + public bool MultipleReaders { get; private set; } + + public QueryRouteCollection(Type routeType) : base(routeType) + { + } + + public override void AddRoute(string? topicFilter, MessageRoute route) + { + base.AddRoute(topicFilter, route); + + if (route.MultipleReaders) + MultipleReaders = true; + } + + public override bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) + { + result = null; + + // Routes without topic filter handle both when the message topic is empty and when it is not, so we always call them + var handled = false; + foreach (var route in _routesWithoutTopicFilter) + { + var thisResult = route.Handle(connection, receiveTime, originalData, data); + if (thisResult != null) + result ??= thisResult; + + handled = true; + } + + // Forward to routes with matching topic filter, if any + if (topicFilter == null) + return handled; + + var matchingTopicRoutes = GetRoutesWithMatchingTopicFilter(topicFilter); + if (matchingTopicRoutes == null) + return handled; + + foreach (var route in matchingTopicRoutes) + { + var thisResult = route.Handle(connection, receiveTime, originalData, data); + handled = true; + + if (thisResult != null) + { + result ??= thisResult; + + if (!MultipleReaders) + break; + } + } + + return handled; + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs b/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs new file mode 100644 index 0000000..48df197 --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs @@ -0,0 +1,65 @@ +using CryptoExchange.Net.Objects; +using System; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif +using System.Collections.Generic; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal abstract class RouteCollection + { + protected List _routesWithoutTopicFilter; + protected Dictionary> _routesWithTopicFilter; +#if NET8_0_OR_GREATER + protected FrozenDictionary>? _routesWithTopicFilterFrozen; +#endif + + public Type DeserializationType { get; } + + public RouteCollection(Type routeType) + { + _routesWithoutTopicFilter = new List(); + _routesWithTopicFilter = new Dictionary>(); + + DeserializationType = routeType; + } + + public virtual void AddRoute(string? topicFilter, MessageRoute route) + { + if (string.IsNullOrEmpty(topicFilter)) + { + _routesWithoutTopicFilter.Add(route); + } + else + { + if (!_routesWithTopicFilter.TryGetValue(topicFilter!, out var list)) + { + list = new List(); + _routesWithTopicFilter.Add(topicFilter!, list); + } + + list.Add(route); + } + } + + public void Build() + { +#if NET8_0_OR_GREATER + _routesWithTopicFilterFrozen = _routesWithTopicFilter.ToFrozenDictionary(); +#endif + } + + protected List? GetRoutesWithMatchingTopicFilter(string topicFilter) + { +#if NET8_0_OR_GREATER + _routesWithTopicFilterFrozen!.TryGetValue(topicFilter, out var matchingTopicRoutes); +#else + _routesWithTopicFilter.TryGetValue(topicFilter, out var matchingTopicRoutes); +#endif + return matchingTopicRoutes; + } + + public abstract bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result); + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs b/CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs new file mode 100644 index 0000000..05548c2 --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs @@ -0,0 +1,111 @@ +using CryptoExchange.Net.Sockets.Interfaces; +using System; +using System.Collections.Generic; +using System.Text; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + /// + /// Routing table + /// + public class RoutingTable + { +#if NET8_0_OR_GREATER + private FrozenDictionary _typeRoutingCollections = new Dictionary().ToFrozenDictionary(); +#else + private Dictionary _typeRoutingCollections = new(); +#endif + + /// + /// Update the routing table + /// + /// + public void Update(IEnumerable processors) + { + var newTypeMap = new Dictionary(); + foreach (var entry in processors) + { + foreach (var route in entry.MessageRouter.Routes) + { + if (!newTypeMap.ContainsKey(route.TypeIdentifier)) + newTypeMap.Add(route.TypeIdentifier, new TypeRoutingCollection(route.DeserializationType)); + + if (!newTypeMap[route.TypeIdentifier].Handlers.Contains(entry)) + newTypeMap[route.TypeIdentifier].Handlers.Add(entry); + } + } + +#if NET8_0_OR_GREATER + _typeRoutingCollections = newTypeMap.ToFrozenDictionary(); +#else + _typeRoutingCollections = newTypeMap; +#endif + } + + /// + /// Get route table entry for a type identifier + /// + public TypeRoutingCollection? GetRouteTableEntry(string typeIdentifier) + { + return _typeRoutingCollections.TryGetValue(typeIdentifier, out var entry) ? entry : null; + } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + foreach (var entry in _typeRoutingCollections) + { + sb.AppendLine($"{entry.Key}, {entry.Value.DeserializationType.Name}"); + foreach(var item in entry.Value.Handlers) + { + sb.AppendLine($" - Processor {item.GetType().Name}"); + foreach(var route in item.MessageRouter.Routes) + { + if (route.TypeIdentifier == entry.Key) + { + if (route.TopicFilter == null) + sb.AppendLine($" - Route without topic filter"); + else + sb.AppendLine($" - Route with topic filter {route.TopicFilter}"); + } + } + } + } + + return sb.ToString(); + } + } + + /// + /// Routing table entry + /// + public record TypeRoutingCollection + { + /// + /// Whether the deserialization type is string + /// + public bool IsStringOutput { get; set; } + /// + /// The deserialization type + /// + public Type DeserializationType { get; set; } + /// + /// Message processors + /// + public List Handlers { get; set; } + + /// + /// ctor + /// + public TypeRoutingCollection(Type deserializationType) + { + IsStringOutput = deserializationType == typeof(string); + DeserializationType = deserializationType; + Handlers = new List(); + } + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs new file mode 100644 index 0000000..613315c --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs @@ -0,0 +1,69 @@ +using CryptoExchange.Net.Objects; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal class SubscriptionRouter : ProcessorRouter + { + public SubscriptionRouter(IEnumerable routes) : base(routes) + { + } + + public override Dictionary BuildFromRoutes(IEnumerable routes) + { + var newMap = new Dictionary(); + foreach (var route in routes) + { + if (!newMap.TryGetValue(route.TypeIdentifier, out var typeMap)) + { + typeMap = new SubscriptionRouteCollection(route.DeserializationType); + newMap.Add(route.TypeIdentifier, typeMap); + } + + typeMap.AddRoute(route.TopicFilter, route); + } + + foreach (var subEntry in newMap.Values) + subEntry.Build(); + + return newMap; + } + } + + internal class SubscriptionRouteCollection : RouteCollection + { + public SubscriptionRouteCollection(Type routeType) : base(routeType) + { + } + + public override bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) + { + result = CallResult.SuccessResult; + + // Routes without topic filter handle both when the message topic is empty and when it is not, so we always call them + var handled = false; + foreach (var route in _routesWithoutTopicFilter) + { + route.Handle(connection, receiveTime, originalData, data); + handled = true; + } + + // Forward to routes with matching topic filter, if any + if (topicFilter == null) + return handled; + + var matchingTopicRoutes = GetRoutesWithMatchingTopicFilter(topicFilter); + if (matchingTopicRoutes == null) + return handled; + + foreach (var route in matchingTopicRoutes) + { + route.Handle(connection, receiveTime, originalData, data); + handled = true; + } + + return handled; + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net/Sockets/Default/SocketConnection.cs b/CryptoExchange.Net/Sockets/Default/SocketConnection.cs index 1abe24a..f9865cd 100644 --- a/CryptoExchange.Net/Sockets/Default/SocketConnection.cs +++ b/CryptoExchange.Net/Sockets/Default/SocketConnection.cs @@ -5,13 +5,12 @@ using CryptoExchange.Net.Logging.Extensions; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Objects.Sockets; using CryptoExchange.Net.Sockets.Default.Interfaces; +using CryptoExchange.Net.Sockets.Default.Routing; using CryptoExchange.Net.Sockets.Interfaces; using Microsoft.Extensions.Logging; using System; -using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics; using System.Linq; using System.Net.WebSockets; using System.Text; @@ -262,6 +261,9 @@ namespace CryptoExchange.Net.Sockets.Default #else private readonly object _listenersLock = new object(); #endif + + private RoutingTable _routingTable = new RoutingTable(); + private ReadOnlyCollection _listeners; private readonly ILogger _logger; private SocketStatus _status; @@ -489,6 +491,14 @@ namespace CryptoExchange.Net.Sockets.Default /// protected internal virtual void HandleStreamMessage2(WebSocketMessageType type, ReadOnlySpan data) { + // Forward message rules: + // | Message Topic | Route Topic Filter | Topics Match | Forward | Description + // | N | N | - | Y | No topic filter applied + // | N | Y | - | N | Route only listens to specific topic + // | Y | N | - | Y | Route listens to all message regardless of topic + // | Y | Y | Y | Y | Route listens to specific message topic + // | Y | Y | N | N | Route listens to different topic + var receiveTime = DateTime.UtcNow; // 1. Decrypt/Preprocess if necessary @@ -521,38 +531,22 @@ namespace CryptoExchange.Net.Sockets.Default return; } - Type? deserializationType = null; - foreach (var subscription in _listeners) - { - foreach (var route in subscription.MessageRouter.Routes) - { - if (!route.TypeIdentifier.Equals(typeIdentifier, StringComparison.Ordinal)) - continue; - - deserializationType = route.DeserializationType; - break; - } - - if (deserializationType != null) - break; - } - - if (deserializationType == null) + var routingEntry = _routingTable.GetRouteTableEntry(typeIdentifier); + if (routingEntry == null) { if (!ApiClient.HandleUnhandledMessage(this, typeIdentifier, data)) { // No handler found for identifier either, can't process _logger.LogWarning("Failed to determine message type for identifier {Identifier}. Data: {Message}", typeIdentifier, Encoding.UTF8.GetString(data.ToArray())); } - + return; } - object result; try { - if (deserializationType == typeof(string)) + if (routingEntry.IsStringOutput) { #if NETSTANDARD2_0 result = Encoding.UTF8.GetString(data.ToArray()); @@ -562,7 +556,7 @@ namespace CryptoExchange.Net.Sockets.Default } else { - result = messageConverter.Deserialize(data, deserializationType); + result = messageConverter.Deserialize(data, routingEntry.DeserializationType); } } catch(Exception ex) @@ -579,60 +573,12 @@ namespace CryptoExchange.Net.Sockets.Default } var topicFilter = messageConverter.GetTopicFilter(result); - - bool processed = false; - foreach (var processor in _listeners) + var processed = false; + foreach (var handler in routingEntry.Handlers) { - bool isQuery = false; - Query? query = null; - if (processor is Query cquery) - { - isQuery = true; - query = cquery; - } - - var complete = false; - - foreach (var route in processor.MessageRouter.Routes) - { - if (route.TypeIdentifier != typeIdentifier) - continue; - - // Forward message rules: - // | Message Topic | Route Topic Filter | Topics Match | Forward | Description - // | N | N | - | Y | No topic filter applied - // | N | Y | - | N | Route only listens to specific topic - // | Y | N | - | Y | Route listens to all message regardless of topic - // | Y | Y | Y | Y | Route listens to specific message topic - // | Y | Y | N | N | Route listens to different topic - if (topicFilter == null) - { - if (route.TopicFilter != null) - // No topic on message, but route is filtering on topic - continue; - } - else - { - if (route.TopicFilter != null && !route.TopicFilter.Equals(topicFilter, StringComparison.Ordinal)) - // Message has a topic, and the route has a filter for another topic - continue; - } - + var thisHandled = handler.Handle(typeIdentifier, topicFilter, this, receiveTime, originalData, result); + if (thisHandled) processed = true; - - if (isQuery && query!.Completed) - continue; - - processor.Handle(this, receiveTime, originalData, result, route); - if (isQuery && !route.MultipleReaders) - { - complete = true; - break; - } - } - - if (complete) - break; } if (!processed) @@ -1193,13 +1139,26 @@ namespace CryptoExchange.Net.Sockets.Default }); } + private void UpdateRoutingTable() + { + _routingTable.Update(_listeners); + } + private void AddMessageProcessor(IMessageProcessor processor) { lock (_listenersLock) { var updatedList = new List(_listeners); updatedList.Add(processor); + processor.OnMessageRouterUpdated += UpdateRoutingTable; _listeners = updatedList.AsReadOnly(); + if (processor.MessageRouter.Routes.Length > 0) + { + UpdateRoutingTable(); +#if DEBUG + _logger.LogTrace("Processor added, new routing table:\r\n" + _routingTable.ToString()); +#endif + } } } @@ -1208,8 +1167,15 @@ namespace CryptoExchange.Net.Sockets.Default lock (_listenersLock) { var updatedList = new List(_listeners); - updatedList.Remove(processor); + processor.OnMessageRouterUpdated -= UpdateRoutingTable; + if (!updatedList.Remove(processor)) + return; // If nothing removed nothing has changed + _listeners = updatedList.AsReadOnly(); + UpdateRoutingTable(); +#if DEBUG + _logger.LogTrace("Processor removed, new routing table:\r\n" + _routingTable.ToString()); +#endif } } @@ -1218,12 +1184,24 @@ namespace CryptoExchange.Net.Sockets.Default lock (_listenersLock) { var updatedList = new List(_listeners); + var anyRemoved = false; foreach (var processor in processors) - updatedList.Remove(processor); + { + processor.OnMessageRouterUpdated -= UpdateRoutingTable; + if (updatedList.Remove(processor)) + anyRemoved = true; + } + + if (!anyRemoved) + return; // If nothing removed nothing has changed + _listeners = updatedList.AsReadOnly(); + UpdateRoutingTable(); +#if DEBUG + _logger.LogTrace("Processors removed, new routing table:\r\n" + _routingTable.ToString()); +#endif } } - } } diff --git a/CryptoExchange.Net/Sockets/Default/Subscription.cs b/CryptoExchange.Net/Sockets/Default/Subscription.cs index fd460e6..9ddc18d 100644 --- a/CryptoExchange.Net/Sockets/Default/Subscription.cs +++ b/CryptoExchange.Net/Sockets/Default/Subscription.cs @@ -1,5 +1,6 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets.Default.Routing; using CryptoExchange.Net.Sockets.Interfaces; using Microsoft.Extensions.Logging; using System; @@ -70,10 +71,21 @@ namespace CryptoExchange.Net.Sockets.Default /// public bool Authenticated { get; } + + private MessageRouter _router; /// /// Router for this subscription /// - public MessageRouter MessageRouter { get; set; } + public MessageRouter MessageRouter + { + get => _router; + set + { + _router = value; + _router.BuildSubscriptionRouter(); + OnMessageRouterUpdated?.Invoke(); + } + } /// /// Cancellation token registration @@ -109,6 +121,9 @@ namespace CryptoExchange.Net.Sockets.Default /// public int IndividualSubscriptionCount { get; set; } = 1; + /// + public event Action? OnMessageRouterUpdated; + /// /// ctor /// @@ -170,10 +185,11 @@ namespace CryptoExchange.Net.Sockets.Default /// /// Handle an update message /// - public CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data, MessageRoute route) + public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data) { ConnectionInvocations++; TotalInvocations++; + if (SubscriptionQuery != null && !SubscriptionQuery.Completed && SubscriptionQuery.TimeoutBehavior == TimeoutBehavior.Succeed) { // The subscription query is one where it is successful if there is no error returned @@ -182,7 +198,7 @@ namespace CryptoExchange.Net.Sockets.Default SubscriptionQuery.Timeout(); } - return route.Handle(connection, receiveTime, originalData, data); + return MessageRouter.Handle(typeIdentifier, topicFilter, connection, receiveTime, originalData, data, out _); } /// diff --git a/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs b/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs index 24e5d5b..7c7561f 100644 --- a/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs +++ b/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs @@ -1,6 +1,7 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; using System; namespace CryptoExchange.Net.Sockets.Interfaces @@ -19,8 +20,12 @@ namespace CryptoExchange.Net.Sockets.Interfaces /// public MessageRouter MessageRouter { get; } /// + /// Event when the message router for this processor has been changed + /// + public event Action? OnMessageRouterUpdated; + /// /// Handle a message /// - CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object result, MessageRoute route); + bool Handle(string typeIdentifier, string? topicFilter, SocketConnection socketConnection, DateTime receiveTime, string? originalData, object result); } } diff --git a/CryptoExchange.Net/Sockets/Query.cs b/CryptoExchange.Net/Sockets/Query.cs index 8769814..60fb635 100644 --- a/CryptoExchange.Net/Sockets/Query.cs +++ b/CryptoExchange.Net/Sockets/Query.cs @@ -1,6 +1,7 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; using CryptoExchange.Net.Sockets.Interfaces; using System; using System.Threading; @@ -59,10 +60,20 @@ namespace CryptoExchange.Net.Sockets /// public object? Response { get; set; } + private MessageRouter _router; /// /// Router for this query /// - public MessageRouter MessageRouter { get; set; } + public MessageRouter MessageRouter + { + get => _router; + set + { + _router = value; + _router.BuildQueryRouter(); + OnMessageRouterUpdated?.Invoke(); + } + } /// /// The query request object @@ -99,6 +110,9 @@ namespace CryptoExchange.Net.Sockets /// public Action? OnComplete { get; set; } + /// + public event Action? OnMessageRouterUpdated; + /// /// ctor /// @@ -155,7 +169,7 @@ namespace CryptoExchange.Net.Sockets /// /// Handle a response message /// - public abstract CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object message, MessageRoute route); + public abstract bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object message); } @@ -185,17 +199,23 @@ namespace CryptoExchange.Net.Sockets } /// - public override CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object message, MessageRoute route) + public override bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object message) { + if (Completed) + return false; + CurrentResponses++; if (CurrentResponses == RequiredResponses) Response = message; + var handled = false; if (Result?.Success != false) { // If an error result is already set don't override that - Result = route.Handle(connection, receiveTime, originalData, message); - if (Result == null) + MessageRouter.Handle(typeIdentifier, topicFilter, connection, receiveTime, originalData, message, out var result); + Result = result; + handled = Result != null; + if (!handled) // Null from Handle means it wasn't actually for this query CurrentResponses -= 1; } @@ -207,7 +227,7 @@ namespace CryptoExchange.Net.Sockets OnComplete?.Invoke(); } - return Result ?? CallResult.SuccessResult; + return handled; } /// diff --git a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs index 9dc30e8..60b4cca 100644 --- a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs +++ b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs @@ -15,6 +15,9 @@ namespace CryptoExchange.Net.Testing if (message.Contains("Received null or empty enum value")) throw new Exception("Enum null error: " + message); + + if (message.Contains("Enum mapping sub-optimal.")) + throw new Exception("Enum mapping error: " + message); } public override void WriteLine(string? message) @@ -27,6 +30,9 @@ namespace CryptoExchange.Net.Testing if (message.Contains("Received null or empty enum value")) throw new Exception("Enum null error: " + message); + + if (message.Contains("Enum mapping sub-optimal.")) + throw new Exception("Enum mapping error: " + message); } } } diff --git a/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs b/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs index 498222a..571f8a6 100644 --- a/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs +++ b/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs @@ -48,6 +48,7 @@ namespace CryptoExchange.Net.Testing /// Method invocation /// Method name for looking up json test values /// Request options + /// Callback to validate the response model /// /// public Task ValidateAsync( @@ -65,6 +66,7 @@ namespace CryptoExchange.Net.Testing /// Method invocation /// Method name for looking up json test values /// Request options + /// Callback to validate the response model /// /// public async Task ValidateAsync(