1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-08-12 17:03:10 +00:00

Compare commits

..

79 Commits

Author SHA1 Message Date
JKorf 0005534a95 Updated version 2023-02-14 20:10:13 +01:00
JKorf 11650f7c1a Updated some interfaces, made time syncing methods nullable for apis not using it, added optional retry checking, removed private key from api credentials, added better support for api credentials subclasses 2023-02-13 21:18:45 +01:00
JKorf a222bb3f02 Fixed socket client options setting, added automatic unsubscribe if the subscription confirmation comes in after request timeout 2023-02-12 14:05:00 +01:00
JKorf 6361c5ef25 Added message on authentication exception 2023-02-08 21:55:56 +01:00
Jkorf 892e8a4508 Updated version 2022-12-08 11:59:02 +01:00
Jkorf 8336d373f3 Small fix for socket possibly reconnecting while it should close 2022-12-08 11:54:10 +01:00
Jkorf 71072680a8 Small fixes DelayAfterConnect option 2022-12-08 11:02:02 +01:00
Jan Korf e13f105019 Merge pull request #167 from alokym86/master
order parameters before constructing request
2022-12-08 11:01:07 +01:00
alokym86 401577451e order parameters
order the request parameters before AuthenticateRequest invocation
2022-12-05 14:55:01 +02:00
JKorf 5c41ef1ee4 Updated version 2022-11-14 21:23:18 +01:00
JKorf ad614830d1 Added optional delay after socket connection, added callback when reconnected socket to revitalize original request, fixed proxy setting socket 2022-11-13 19:47:33 +01:00
JKorf 3365837338 Updated tests 2022-11-13 15:31:28 +01:00
JKorf 66ac2972d6 wip 2022-11-02 17:59:33 +01:00
JKorf 0d3e05880a Wip client work 2022-10-31 21:41:30 +01:00
JKorf 997e71f3b7 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2022-10-28 20:02:37 +02:00
JKorf b0fca4587d Added null check array converter 2022-10-28 20:02:25 +02:00
Jan Korf c10671768d Merge pull request #160 from kulikov-dev/fix/155-format-conversion-error
Fixing exponential format parsing
2022-10-15 13:34:00 +02:00
kulikov-dev 91e8123679 Fixing issue 155 with exponential format parsing (https://github.com/JKorf/CryptoExchange.Net/issues/155) 2022-10-15 09:57:14 +06:00
JKorf 417cf2f9ac Fixed nullability warning 2022-07-31 21:55:26 +02:00
JKorf 277be7ab9b Updated version 2022-07-31 21:53:39 +02:00
JKorf 45f3459f59 Made DataEvent ctor public 2022-07-31 21:47:22 +02:00
JKorf 98dad4a8ed Added handling for websocket options not being supported when running on WebAssembly 2022-07-31 21:45:19 +02:00
JKorf 1e5f19271b Updated support docs 2022-07-31 14:51:47 +02:00
JKorf 8abeeb4cf0 Update Clients.md 2022-07-29 18:57:04 +02:00
JKorf cae0cd9ead Fixed EnumConverter serialization writing values without quotes 2022-07-27 21:26:17 +02:00
JKorf 811574ae01 Fixed websocket reconnecting too fast when reconnecting succeeds but resubscribing or authorization fails 2022-07-27 21:25:46 +02:00
JKorf 0ddecf7f8d Updated version 2022-07-19 19:13:26 +02:00
JKorf 5bcf50fb4d Fixed socket getting disconnected when no data timeout reached instead of being reconnected 2022-07-19 19:12:26 +02:00
JKorf 9f0654815d Updated version 2022-07-17 12:50:50 +02:00
JKorf 465e9f04f4 Added support for retrieving a reconnection url when socket connection is lost 2022-07-17 12:49:13 +02:00
JKorf 7c8cbfa4e2 Updated version 2022-07-16 21:06:30 +02:00
JKorf 4c79d13ff9 Set error to the response content when an error response is received which isn't json 2022-07-15 16:55:18 +02:00
JKorf c815fad135 Fix for Message not handled when closing subscription, fix for reconnect loop 2022-07-12 22:06:38 +02:00
JKorf 41f17d0378 Don't close socket after failed auth when already closing 2022-07-11 18:56:51 +02:00
JKorf 50715ff2f7 Squashed commit of the following:
commit 0571ed17a0
Author: JKorf <jankorf91@gmail.com>
Date:   Sun Jul 10 19:56:27 2022 +0200

    Fixed tests

commit 99c331b389
Author: JKorf <jankorf91@gmail.com>
Date:   Sun Jul 10 16:41:14 2022 +0200

    Updated version

commit 70f8bd203a
Author: JKorf <jankorf91@gmail.com>
Date:   Sun Jul 10 16:36:00 2022 +0200

    Finished up websocket refactoring

commit 89b517c936
Author: JKorf <jankorf91@gmail.com>
Date:   Fri Jul 8 20:24:58 2022 +0200

    wip

commit 91e33cc42c
Author: JKorf <jankorf91@gmail.com>
Date:   Thu Jul 7 22:17:55 2022 +0200

    wip
2022-07-10 19:57:10 +02:00
JKorf ea9375d582 Updated version 2022-06-12 15:36:04 +02:00
JKorf 2cf3c93e5e Cleanup 2022-06-12 15:35:35 +02:00
JKorf ca888d8e41 Updated version 2022-06-12 15:31:03 +02:00
JKorf 2040b1c175 Fixed proxy setting not used on reconnecting socket 2022-06-12 15:26:11 +02:00
JKorf d451c18821 No longer waiting for timesyncing to complete when it's not the first request 2022-06-12 15:21:22 +02:00
JKorf c13dfa4461 Updated socket reconnection 2022-06-12 15:10:10 +02:00
JKorf c2080ef75f Made MaxSocketConnections a setting, added support for changing log settings after creating client 2022-06-11 13:31:39 +02:00
Jan Korf 6b252e8024 Update TestSocket.cs 2022-05-24 22:36:55 +02:00
Jan Korf d06bd5f176 Updated version 2022-05-24 18:56:37 +02:00
Jan Korf d55fc8da65 Merge pull request #144 from tamaw/fix/missing-port-on-baseuri
Fixed: copying the port number when using a custom BaseAddress
2022-05-24 15:14:38 +02:00
Tama Waddell 01184f2c5d Added port to the other overloaded method 2022-05-24 21:13:08 +10:00
Jan Korf cadc93c2f0 Merge pull request #143 from andriibratanin/bugfix/fix-nuget-discovery
Fix NuGet packages discovery for some IDEs
2022-05-24 10:21:21 +02:00
Tama Waddell 2600a51461 Included copying the port when using SetParameters 2022-05-24 15:16:06 +10:00
Andrii Bratanin 9e6a86ba8b Fix wrong case in csproj files of tests projects #142 2022-05-24 01:31:25 +03:00
Jan Korf c4430d63fa Added KeepAliveInterval setting for socket connections 2022-05-23 22:05:04 +02:00
Jan Korf f3e1cfef33 Updated version 2022-05-22 15:51:48 +02:00
Jan Korf cc3053719c Make socket ConnectionLost run in a separate task to prevent issue with long running/exceptions in the handler 2022-05-22 15:46:30 +02:00
Jan Korf cd6907e601 Merge branch 'master' of https://github.com/jkorf/CryptoExchange.Net 2022-05-22 14:35:07 +02:00
Jan Korf 8fe00693bd Merge pull request #141 from nathan-datusarator/master
Add checks for Disposed
2022-05-22 11:41:55 +02:00
Jan Korf fb90d1e015 Fixed exception when disposing client in reconnecting state 2022-05-22 11:35:39 +02:00
Jan Korf 4b44861e43 Added additional cases for no null/default handling in DateTimeConverter 2022-05-22 11:35:17 +02:00
Jan Korf e42ca4ab5a Update FAQ.md 2022-05-21 10:17:59 +02:00
Nathan Pfluger 5b97f6dd67 Move Subscription Events into non-lambda so they can be removed on StopAsync 2022-05-12 10:00:44 -07:00
Nathan Pfluger a9813ecb0a Add checks for Disposed 2022-05-12 09:05:27 -07:00
Jan Korf c7069a4049 Updated version 2022-05-08 16:28:17 +02:00
Jan Korf 5683ae0b3c Small fix when closing socket 2022-05-08 16:25:45 +02:00
Jan Korf 1c8cf5ac98 Updated timestamp calculation to include latency 2022-05-08 15:23:47 +02:00
Jan Korf ad7231ec56 Updated version 2022-05-01 13:59:56 +02:00
Jan Korf 7e4a607391 Fixed datetime converter considering dates over 2033 to be in the wrong format 2022-05-01 13:57:06 +02:00
Jan Korf 2d470d18e2 Added support for sending request with empty response 2022-05-01 13:50:23 +02:00
Jan Korf cb9a766c3b Logging 2022-04-30 18:31:14 +02:00
Jan Korf 94b8184f7b Added handling for websocket send failing 2022-04-30 16:14:17 +02:00
Jan Korf 270ea06f24 Update SocketConnection.cs 2022-04-30 13:25:57 +02:00
Jan Korf 536afa92da wip 2022-04-24 15:25:50 +02:00
Jan Korf 11c48b3341 wip 2022-04-24 11:31:13 +02:00
Jan Korf f514e172d7 wip 2022-04-24 09:29:08 +02:00
Jan Korf 1739769f87 Updated version 2022-04-14 15:40:51 +02:00
Jan Korf 7ccf643a34 Fixed tests 2022-04-14 15:09:38 +02:00
Jan Korf edfaa650bf Moved some parameters from BaseRestClient to RestApiClient to support different setting between different sub api's 2022-04-14 15:06:52 +02:00
Jan Korf 13c81afb79 Merge pull request #135 from mohammadj22/ImproveWebSocketClientProxy
change SetProxy method in web socket client to support socks5 proxy.
2022-04-14 15:05:19 +02:00
Mohammad Reza c4f4ddcdc5 Update SetProxy 2022-04-13 17:37:48 +04:30
Mohammad Reza 4f4d2ccff3 add Schema check 2022-04-13 13:18:38 +04:30
Mohammad Reza 4db43517b7 change SetProxy method to support socks5 proxy. 2022-04-07 12:44:12 +04:30
Jkorf 41f38e040e Added missing SetApiCredentials on socket client 2022-03-24 15:47:06 +01:00
45 changed files with 3412 additions and 2562 deletions
@@ -16,7 +16,7 @@ namespace CryptoExchange.Net.UnitTests
{
// arrange
var logger = new TestStringLogger();
var client = new TestBaseClient(new BaseRestClientOptions()
var client = new TestBaseClient(new TestOptions()
{
LogWriters = new List<ILogger> { logger }
});
@@ -56,7 +56,7 @@ namespace CryptoExchange.Net.UnitTests
{
// arrange
var logger = new TestStringLogger();
var options = new BaseRestClientOptions()
var options = new TestOptions()
{
LogWriters = new List<ILogger> { logger }
};
@@ -78,7 +78,7 @@ namespace CryptoExchange.Net.UnitTests
var client = new TestBaseClient();
// act
var result = client.Deserialize<object>("{\"testProperty\": 123}");
var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123}");
// assert
Assert.IsTrue(result.Success);
@@ -91,7 +91,7 @@ namespace CryptoExchange.Net.UnitTests
var client = new TestBaseClient();
// act
var result = client.Deserialize<object>("{\"testProperty\": 123");
var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123");
// assert
Assert.IsFalse(result.Success);
@@ -6,10 +6,10 @@
</PropertyGroup>
<ItemGroup>
<packagereference Include="Microsoft.NET.Test.Sdk" Version="17.1.0-preview-20211130-02"></packagereference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0-preview-20211130-02"></PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<packagereference Include="NUnit" Version="3.13.2"></packagereference>
<packagereference Include="NUnit3TestAdapter" Version="4.2.0"></packagereference>
<PackageReference Include="NUnit" Version="3.13.2"></PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="4.2.0"></PackageReference>
</ItemGroup>
<ItemGroup>
+1 -1
View File
@@ -248,7 +248,7 @@ namespace CryptoExchange.Net.UnitTests
}
}
public class TestClientOptions: BaseRestClientOptions
public class TestClientOptions: ClientOptions
{
/// <summary>
/// Default options for the futures client
+11 -11
View File
@@ -28,7 +28,7 @@ namespace CryptoExchange.Net.UnitTests
client.SetResponse(JsonConvert.SerializeObject(expected), out _);
// act
var result = client.Request<TestObject>().Result;
var result = client.Api1.Request<TestObject>().Result;
// assert
Assert.IsTrue(result.Success);
@@ -43,7 +43,7 @@ namespace CryptoExchange.Net.UnitTests
client.SetResponse("{\"property\": 123", out _);
// act
var result = client.Request<TestObject>().Result;
var result = client.Api1.Request<TestObject>().Result;
// assert
Assert.IsFalse(result.Success);
@@ -58,7 +58,7 @@ namespace CryptoExchange.Net.UnitTests
client.SetErrorWithoutResponse(System.Net.HttpStatusCode.BadRequest, "Invalid request");
// act
var result = await client.Request<TestObject>();
var result = await client.Api1.Request<TestObject>();
// assert
Assert.IsFalse(result.Success);
@@ -73,7 +73,7 @@ namespace CryptoExchange.Net.UnitTests
client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
// act
var result = await client.Request<TestObject>();
var result = await client.Api1.Request<TestObject>();
// assert
Assert.IsFalse(result.Success);
@@ -91,7 +91,7 @@ namespace CryptoExchange.Net.UnitTests
client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
// act
var result = await client.Request<TestObject>();
var result = await client.Api2.Request<TestObject>();
// assert
Assert.IsFalse(result.Success);
@@ -112,9 +112,9 @@ namespace CryptoExchange.Net.UnitTests
{
BaseAddress = "http://test.address.com",
RateLimiters = new List<IRateLimiter> { new RateLimiter() },
RateLimitingBehaviour = RateLimitingBehaviour.Fail
},
RequestTimeout = TimeSpan.FromMinutes(1)
RateLimitingBehaviour = RateLimitingBehaviour.Fail,
RequestTimeout = TimeSpan.FromMinutes(1)
}
});
@@ -122,7 +122,7 @@ namespace CryptoExchange.Net.UnitTests
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.BaseAddress == "http://test.address.com");
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.RateLimiters.Count == 1);
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.RateLimitingBehaviour == RateLimitingBehaviour.Fail);
Assert.IsTrue(client.ClientOptions.RequestTimeout == TimeSpan.FromMinutes(1));
Assert.IsTrue(((TestClientOptions)client.ClientOptions).Api1Options.RequestTimeout == TimeSpan.FromMinutes(1));
}
[TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid
@@ -144,11 +144,11 @@ namespace CryptoExchange.Net.UnitTests
}
});
client.SetParameterPosition(new HttpMethod(method), pos);
client.Api1.SetParameterPosition(new HttpMethod(method), pos);
client.SetResponse("{}", out var request);
await client.RequestWithParams<TestObject>(new HttpMethod(method), new Dictionary<string, object>
await client.Api1.RequestWithParams<TestObject>(new HttpMethod(method), new Dictionary<string, object>
{
{ "TestParam1", "Value1" },
{ "TestParam2", 2 },
@@ -1,5 +1,6 @@
using System;
using System.Threading;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
using CryptoExchange.Net.UnitTests.TestImplementations;
@@ -19,17 +20,17 @@ namespace CryptoExchange.Net.UnitTests
//act
var client = new TestSocketClient(new TestOptions()
{
SubOptions = new RestApiClientOptions
SubOptions = new SocketApiClientOptions
{
BaseAddress = "http://test.address.com"
},
ReconnectInterval = TimeSpan.FromSeconds(6)
BaseAddress = "http://test.address.com",
ReconnectInterval = TimeSpan.FromSeconds(6)
}
});
//assert
Assert.IsTrue(client.SubClient.Options.BaseAddress == "http://test.address.com");
Assert.IsTrue(client.ClientOptions.ReconnectInterval.TotalSeconds == 6);
Assert.IsTrue(client.SubClient.Options.ReconnectInterval.TotalSeconds == 6);
}
[TestCase(true)]
@@ -42,7 +43,7 @@ namespace CryptoExchange.Net.UnitTests
socket.CanConnect = canConnect;
//act
var connectResult = client.ConnectSocketSub(new SocketConnection(client, null, socket));
var connectResult = client.SubClient.ConnectSocketSub(new SocketConnection(new Log(""), client.SubClient, socket, null));
//assert
Assert.IsTrue(connectResult.Success == canConnect);
@@ -52,20 +53,26 @@ namespace CryptoExchange.Net.UnitTests
public void SocketMessages_Should_BeProcessedInDataHandlers()
{
// arrange
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
var client = new TestSocketClient(new TestOptions() {
SubOptions = new SocketApiClientOptions
{
ReconnectInterval = TimeSpan.Zero,
},
LogLevel = LogLevel.Debug
});
var socket = client.CreateSocket();
socket.ShouldReconnect = true;
socket.CanConnect = true;
socket.DisconnectTime = DateTime.UtcNow;
var sub = new SocketConnection(client, null, socket);
var sub = new SocketConnection(new Log(""), client.SubClient, socket, null);
var rstEvent = new ManualResetEvent(false);
JToken result = null;
sub.AddSubscription(SocketSubscription.CreateForIdentifier(10, "TestHandler", true, (messageEvent) =>
sub.AddSubscription(SocketSubscription.CreateForIdentifier(10, "TestHandler", true, false, (messageEvent) =>
{
result = messageEvent.JsonData;
rstEvent.Set();
}));
client.ConnectSocketSub(sub);
client.SubClient.ConnectSocketSub(sub);
// act
socket.InvokeMessage("{\"property\": 123}");
@@ -80,20 +87,27 @@ namespace CryptoExchange.Net.UnitTests
public void SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled)
{
// arrange
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug, OutputOriginalData = enabled });
var client = new TestSocketClient(new TestOptions() {
SubOptions = new SocketApiClientOptions
{
ReconnectInterval = TimeSpan.Zero,
OutputOriginalData = enabled
},
LogLevel = LogLevel.Debug,
});
var socket = client.CreateSocket();
socket.ShouldReconnect = true;
socket.CanConnect = true;
socket.DisconnectTime = DateTime.UtcNow;
var sub = new SocketConnection(client, null, socket);
var sub = new SocketConnection(new Log(""), client.SubClient, socket, null);
var rstEvent = new ManualResetEvent(false);
string original = null;
sub.AddSubscription(SocketSubscription.CreateForIdentifier(10, "TestHandler", true, (messageEvent) =>
sub.AddSubscription(SocketSubscription.CreateForIdentifier(10, "TestHandler", true, false, (messageEvent) =>
{
original = messageEvent.OriginalData;
rstEvent.Set();
}));
client.ConnectSocketSub(sub);
client.SubClient.ConnectSocketSub(sub);
// act
socket.InvokeMessage("{\"property\": 123}");
@@ -103,44 +117,25 @@ namespace CryptoExchange.Net.UnitTests
Assert.IsTrue(original == (enabled ? "{\"property\": 123}" : null));
}
[TestCase]
public void DisconnectedSocket_Should_Reconnect()
{
// arrange
bool reconnected = false;
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
var socket = client.CreateSocket();
socket.ShouldReconnect = true;
socket.CanConnect = true;
socket.DisconnectTime = DateTime.UtcNow;
var sub = new SocketConnection(client, null, socket);
sub.ShouldReconnect = true;
client.ConnectSocketSub(sub);
var rstEvent = new ManualResetEvent(false);
sub.ConnectionRestored += (a) =>
{
reconnected = true;
rstEvent.Set();
};
// act
socket.InvokeClose();
rstEvent.WaitOne(1000);
// assert
Assert.IsTrue(reconnected);
}
[TestCase()]
public void UnsubscribingStream_Should_CloseTheSocket()
{
// arrange
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
var client = new TestSocketClient(new TestOptions()
{
SubOptions = new SocketApiClientOptions
{
ReconnectInterval = TimeSpan.Zero,
},
LogLevel = LogLevel.Debug
});
var socket = client.CreateSocket();
socket.CanConnect = true;
var sub = new SocketConnection(client, null, socket);
client.ConnectSocketSub(sub);
var ups = new UpdateSubscription(sub, SocketSubscription.CreateForIdentifier(10, "Test", true, (e) => {}));
var sub = new SocketConnection(new Log(""), client.SubClient, socket, null);
client.SubClient.ConnectSocketSub(sub);
var us = SocketSubscription.CreateForIdentifier(10, "Test", true, false, (e) => { });
var ups = new UpdateSubscription(sub, us);
sub.AddSubscription(us);
// act
client.UnsubscribeAsync(ups).Wait();
@@ -153,15 +148,22 @@ namespace CryptoExchange.Net.UnitTests
public void UnsubscribingAll_Should_CloseAllSockets()
{
// arrange
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
var client = new TestSocketClient(new TestOptions()
{
SubOptions = new SocketApiClientOptions
{
ReconnectInterval = TimeSpan.Zero,
},
LogLevel = LogLevel.Debug
});
var socket1 = client.CreateSocket();
var socket2 = client.CreateSocket();
socket1.CanConnect = true;
socket2.CanConnect = true;
var sub1 = new SocketConnection(client, null, socket1);
var sub2 = new SocketConnection(client, null, socket2);
client.ConnectSocketSub(sub1);
client.ConnectSocketSub(sub2);
var sub1 = new SocketConnection(new Log(""), client.SubClient, socket1, null);
var sub2 = new SocketConnection(new Log(""), client.SubClient, socket2, null);
client.SubClient.ConnectSocketSub(sub1);
client.SubClient.ConnectSocketSub(sub2);
// act
client.UnsubscribeAllAsync().Wait();
@@ -175,13 +177,20 @@ namespace CryptoExchange.Net.UnitTests
public void FailingToConnectSocket_Should_ReturnError()
{
// arrange
var client = new TestSocketClient(new TestOptions() { ReconnectInterval = TimeSpan.Zero, LogLevel = LogLevel.Debug });
var client = new TestSocketClient(new TestOptions()
{
SubOptions = new SocketApiClientOptions
{
ReconnectInterval = TimeSpan.Zero,
},
LogLevel = LogLevel.Debug
});
var socket = client.CreateSocket();
socket.CanConnect = false;
var sub = new SocketConnection(client, null, socket);
var sub1 = new SocketConnection(new Log(""), client.SubClient, socket, null);
// act
var connectResult = client.ConnectSocketSub(sub);
var connectResult = client.SubClient.ConnectSocketSub(sub1);
// assert
Assert.IsFalse(connectResult.Success);
@@ -1,19 +1,25 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.UnitTests.TestImplementations;
using Microsoft.Extensions.Logging;
namespace CryptoExchange.Net.UnitTests
{
public class TestBaseClient: BaseClient
{
public TestBaseClient(): base("Test", new BaseClientOptions())
public TestSubClient SubClient { get; }
public TestBaseClient(): base("Test", new TestOptions())
{
SubClient = AddApiClient(new TestSubClient(new TestOptions(), new RestApiClientOptions()));
}
public TestBaseClient(BaseRestClientOptions exchangeOptions) : base("Test", exchangeOptions)
public TestBaseClient(ClientOptions exchangeOptions) : base("Test", exchangeOptions)
{
}
@@ -21,11 +27,20 @@ namespace CryptoExchange.Net.UnitTests
{
log.Write(verbosity, data);
}
}
public CallResult<T> Deserialize<T>(string data)
public class TestSubClient : RestApiClient
{
public TestSubClient(ClientOptions options, RestApiClientOptions apiOptions) : base(new Log(""), options, apiOptions)
{
return Deserialize<T>(data, null, null);
}
public CallResult<T> Deserialize<T>(string data) => Deserialize<T>(data, null, null);
public override TimeSpan? GetTimeOffset() => null;
public override TimeSyncInfo GetTimeSyncInfo() => null;
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException();
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
}
public class TestAuthProvider : AuthenticationProvider
@@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using System.Collections.Generic;
using CryptoExchange.Net.Logging;
namespace CryptoExchange.Net.UnitTests.TestImplementations
{
@@ -28,12 +29,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
{
Api1 = new TestRestApi1Client(exchangeOptions);
Api2 = new TestRestApi2Client(exchangeOptions);
RequestFactory = new Mock<IRequestFactory>().Object;
}
public void SetParameterPosition(HttpMethod method, HttpMethodParameterPosition position)
{
ParameterPositions[method] = position;
}
public void SetResponse(string responseData, out IRequest requestObj)
@@ -55,7 +50,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(key, new List<string> { val }));
request.Setup(c => c.GetHeaders()).Returns(() => headers);
var factory = Mock.Get(RequestFactory);
var factory = Mock.Get(Api1.RequestFactory);
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
.Callback<HttpMethod, Uri, int>((method, uri, id) =>
{
@@ -63,6 +58,15 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
request.Setup(a => a.Method).Returns(method);
})
.Returns(request.Object);
factory = Mock.Get(Api2.RequestFactory);
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
.Callback<HttpMethod, Uri, int>((method, uri, id) =>
{
request.Setup(a => a.Uri).Returns(uri);
request.Setup(a => a.Method).Returns(method);
})
.Returns(request.Object);
requestObj = request.Object;
}
@@ -76,7 +80,12 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
request.Setup(c => c.GetHeaders()).Returns(new Dictionary<string, IEnumerable<string>>());
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Throws(we);
var factory = Mock.Get(RequestFactory);
var factory = Mock.Get(Api1.RequestFactory);
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
.Returns(request.Object);
factory = Mock.Get(Api2.RequestFactory);
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
.Returns(request.Object);
}
@@ -99,31 +108,41 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(key, new List<string> { val }));
request.Setup(c => c.GetHeaders()).Returns(headers);
var factory = Mock.Get(RequestFactory);
var factory = Mock.Get(Api1.RequestFactory);
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
.Callback<HttpMethod, Uri, int>((method, uri, id) => request.Setup(a => a.Uri).Returns(uri))
.Returns(request.Object);
}
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T:class
{
return await SendRequestAsync<T>(Api1, new Uri("http://www.test.com"), HttpMethod.Get, ct);
}
public async Task<CallResult<T>> RequestWithParams<T>(HttpMethod method, Dictionary<string, object> parameters, Dictionary<string, string> headers) where T : class
{
return await SendRequestAsync<T>(Api1, new Uri("http://www.test.com"), method, default, parameters, additionalHeaders: headers);
factory = Mock.Get(Api2.RequestFactory);
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
.Callback<HttpMethod, Uri, int>((method, uri, id) => request.Setup(a => a.Uri).Returns(uri))
.Returns(request.Object);
}
}
public class TestRestApi1Client : RestApiClient
{
public TestRestApi1Client(TestClientOptions options): base(options, options.Api1Options)
public TestRestApi1Client(TestClientOptions options): base(new Log(""), options, options.Api1Options)
{
RequestFactory = new Mock<IRequestFactory>().Object;
}
public override TimeSpan GetTimeOffset()
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T : class
{
return await SendRequestAsync<T>(new Uri("http://www.test.com"), HttpMethod.Get, ct);
}
public async Task<CallResult<T>> RequestWithParams<T>(HttpMethod method, Dictionary<string, object> parameters, Dictionary<string, string> headers) where T : class
{
return await SendRequestAsync<T>(new Uri("http://www.test.com"), method, default, parameters, additionalHeaders: headers);
}
public void SetParameterPosition(HttpMethod method, HttpMethodParameterPosition position)
{
ParameterPositions[method] = position;
}
public override TimeSpan? GetTimeOffset()
{
throw new NotImplementedException();
}
@@ -136,7 +155,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
throw new NotImplementedException();
}
protected override TimeSyncInfo GetTimeSyncInfo()
public override TimeSyncInfo GetTimeSyncInfo()
{
throw new NotImplementedException();
}
@@ -144,12 +163,22 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
public class TestRestApi2Client : RestApiClient
{
public TestRestApi2Client(TestClientOptions options) : base(options, options.Api2Options)
public TestRestApi2Client(TestClientOptions options) : base(new Log(""), options, options.Api2Options)
{
RequestFactory = new Mock<IRequestFactory>().Object;
}
public override TimeSpan GetTimeOffset()
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T : class
{
return await SendRequestAsync<T>(new Uri("http://www.test.com"), HttpMethod.Get, ct);
}
protected override Error ParseErrorResponse(JToken error)
{
return new ServerError((int)error["errorCode"], (string)error["errorMessage"]);
}
public override TimeSpan? GetTimeOffset()
{
throw new NotImplementedException();
}
@@ -162,7 +191,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
throw new NotImplementedException();
}
protected override TimeSyncInfo GetTimeSyncInfo()
public override TimeSyncInfo GetTimeSyncInfo()
{
throw new NotImplementedException();
}
@@ -187,9 +216,5 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
public ParseErrorTestRestClient() { }
public ParseErrorTestRestClient(TestClientOptions exchangeOptions) : base(exchangeOptions) { }
protected override Error ParseErrorResponse(JToken error)
{
return new ServerError((int)error["errorCode"], (string)error["errorMessage"]);
}
}
}
@@ -13,9 +13,15 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
public bool Connected { get; set; }
public event Action OnClose;
#pragma warning disable 0067
public event Action OnReconnected;
public event Action OnReconnecting;
#pragma warning restore 0067
public event Action<string> OnMessage;
public event Action<Exception> OnError;
public event Action OnOpen;
public Func<Task<Uri>> GetReconnectionUrl { get; set; }
public int Id { get; }
public bool ShouldReconnect { get; set; }
@@ -38,6 +44,10 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
public double IncomingKbps => throw new NotImplementedException();
public Uri Uri => new Uri("");
public TimeSpan KeepAliveInterval { get; set; }
public static int lastId = 0;
public static object lastIdLock = new object();
@@ -89,6 +99,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
{
Connected = false;
DisconnectTime = DateTime.UtcNow;
Reconnecting = true;
OnClose?.Invoke();
}
@@ -111,5 +122,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
{
OnError?.Invoke(error);
}
public Task ReconnectAsync() => Task.CompletedTask;
}
}
@@ -20,16 +20,39 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
public TestSocketClient(TestOptions exchangeOptions) : base("test", exchangeOptions)
{
SubClient = new TestSubSocketClient(exchangeOptions, exchangeOptions.SubOptions);
SocketFactory = new Mock<IWebsocketFactory>().Object;
Mock.Get(SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<Log>(), It.IsAny<string>())).Returns(new TestSocket());
SubClient = AddApiClient(new TestSubSocketClient(exchangeOptions, exchangeOptions.SubOptions));
SubClient.SocketFactory = new Mock<IWebsocketFactory>().Object;
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<Log>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket());
}
public TestSocket CreateSocket()
{
Mock.Get(SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<Log>(), It.IsAny<string>())).Returns(new TestSocket());
return (TestSocket)CreateSocket("123");
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<Log>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket());
return (TestSocket)SubClient.CreateSocketInternal("https://localhost:123/");
}
}
public class TestOptions: ClientOptions
{
public SocketApiClientOptions SubOptions { get; set; } = new SocketApiClientOptions();
}
public class TestSubSocketClient : SocketApiClient
{
public TestSubSocketClient(ClientOptions options, SocketApiClientOptions apiOptions): base(new Log(""), options, apiOptions)
{
}
internal IWebsocket CreateSocketInternal(string address)
{
return CreateSocket(address);
}
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
=> new TestAuthProvider(credentials);
public CallResult<bool> ConnectSocketSub(SocketConnection sub)
{
@@ -67,21 +90,4 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
throw new NotImplementedException();
}
}
public class TestOptions: BaseSocketClientOptions
{
public ApiClientOptions SubOptions { get; set; } = new ApiClientOptions();
}
public class TestSubSocketClient : SocketApiClient
{
public TestSubSocketClient(BaseClientOptions options, ApiClientOptions apiOptions): base(options, apiOptions)
{
}
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
=> new TestAuthProvider(credentials);
}
}
@@ -21,20 +21,6 @@ namespace CryptoExchange.Net.Authentication
/// </summary>
public SecureString? Secret { get; }
/// <summary>
/// The private key to authenticate requests
/// </summary>
public PrivateKey? PrivateKey { get; }
/// <summary>
/// Create Api credentials providing a private key for authentication
/// </summary>
/// <param name="privateKey">The private key used for signing</param>
public ApiCredentials(PrivateKey privateKey)
{
PrivateKey = privateKey;
}
/// <summary>
/// Create Api credentials providing an api key and secret for authentication
/// </summary>
@@ -69,11 +55,8 @@ namespace CryptoExchange.Net.Authentication
/// <returns></returns>
public virtual ApiCredentials Copy()
{
if (PrivateKey == null)
// Use .GetString() to create a copy of the SecureString
return new ApiCredentials(Key!.GetString(), Secret!.GetString());
else
return new ApiCredentials(PrivateKey!.Copy());
// Use .GetString() to create a copy of the SecureString
return new ApiCredentials(Key!.GetString(), Secret!.GetString());
}
/// <summary>
@@ -123,7 +106,6 @@ namespace CryptoExchange.Net.Authentication
{
Key?.Dispose();
Secret?.Dispose();
PrivateKey?.Dispose();
}
}
}
@@ -223,7 +223,7 @@ namespace CryptoExchange.Net.Authentication
/// <returns></returns>
protected static DateTime GetTimestamp(RestApiClient apiClient)
{
return DateTime.UtcNow.Add(apiClient?.GetTimeOffset() ?? TimeSpan.Zero)!;
return DateTime.UtcNow.Add(apiClient.GetTimeOffset() ?? TimeSpan.Zero)!;
}
/// <summary>
@@ -1,110 +0,0 @@
using System;
using System.Security;
namespace CryptoExchange.Net.Authentication
{
/// <summary>
/// Private key info
/// </summary>
public class PrivateKey : IDisposable
{
/// <summary>
/// The private key
/// </summary>
public SecureString Key { get; }
/// <summary>
/// The private key's pass phrase
/// </summary>
public SecureString? Passphrase { get; }
/// <summary>
/// Indicates if the private key is encrypted or not
/// </summary>
public bool IsEncrypted { get; }
/// <summary>
/// Create a private key providing an encrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
/// <param name="passphrase">The private key's passphrase</param>
public PrivateKey(SecureString key, SecureString passphrase)
{
Key = key;
Passphrase = passphrase;
IsEncrypted = true;
}
/// <summary>
/// Create a private key providing an encrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
/// <param name="passphrase">The private key's passphrase</param>
public PrivateKey(string key, string passphrase)
{
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(passphrase))
throw new ArgumentException("Key and passphrase can't be null/empty");
var secureKey = new SecureString();
foreach (var c in key)
secureKey.AppendChar(c);
secureKey.MakeReadOnly();
Key = secureKey;
var securePassphrase = new SecureString();
foreach (var c in passphrase)
securePassphrase.AppendChar(c);
securePassphrase.MakeReadOnly();
Passphrase = securePassphrase;
IsEncrypted = true;
}
/// <summary>
/// Create a private key providing an unencrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
public PrivateKey(SecureString key)
{
Key = key;
IsEncrypted = false;
}
/// <summary>
/// Create a private key providing an encrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
public PrivateKey(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key can't be null/empty");
Key = key.ToSecureString();
IsEncrypted = false;
}
/// <summary>
/// Copy the private key
/// </summary>
/// <returns></returns>
public PrivateKey Copy()
{
if (Passphrase == null)
return new PrivateKey(Key.GetString());
else
return new PrivateKey(Key.GetString(), Passphrase.GetString());
}
/// <summary>
/// Dispose
/// </summary>
public void Dispose()
{
Key?.Dispose();
Passphrase?.Dispose();
}
}
}
+287 -10
View File
@@ -1,25 +1,45 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net
{
/// <summary>
/// Base API for all API clients
/// </summary>
public abstract class BaseApiClient: IDisposable
public abstract class BaseApiClient : IDisposable, IBaseApiClient
{
private ApiCredentials? _apiCredentials;
private AuthenticationProvider? _authenticationProvider;
private bool _created;
private bool _disposing;
/// <summary>
/// Logger
/// </summary>
protected Log _log;
/// <summary>
/// If we are disposing
/// </summary>
protected bool _disposing;
/// <summary>
/// The authentication provider for this API client. (null if no credentials are set)
/// </summary>
public AuthenticationProvider? AuthenticationProvider
{
get
get
{
if (!_created && !_disposing && _apiCredentials != null)
{
@@ -31,25 +51,76 @@ namespace CryptoExchange.Net
}
}
/// <summary>
/// Where to put the parameters for requests with different Http methods
/// </summary>
public Dictionary<HttpMethod, HttpMethodParameterPosition> ParameterPositions { get; set; } = new Dictionary<HttpMethod, HttpMethodParameterPosition>
{
{ HttpMethod.Get, HttpMethodParameterPosition.InUri },
{ HttpMethod.Post, HttpMethodParameterPosition.InBody },
{ HttpMethod.Delete, HttpMethodParameterPosition.InBody },
{ HttpMethod.Put, HttpMethodParameterPosition.InBody }
};
/// <summary>
/// Request body content type
/// </summary>
public RequestBodyFormat requestBodyFormat = RequestBodyFormat.Json;
/// <summary>
/// Whether or not we need to manually parse an error instead of relying on the http status code
/// </summary>
public bool manualParseError = false;
/// <summary>
/// How to serialize array parameters when making requests
/// </summary>
public ArrayParametersSerialization arraySerialization = ArrayParametersSerialization.Array;
/// <summary>
/// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody)
/// </summary>
public string requestBodyEmptyContent = "{}";
/// <summary>
/// The base address for this API client
/// </summary>
internal protected string BaseAddress { get; }
/// <summary>
/// Api client options
/// Options
/// </summary>
internal ApiClientOptions Options { get; }
public ApiClientOptions Options { get; }
/// <summary>
/// The last used id, use NextId() to get the next id and up this
/// </summary>
protected static int lastId;
/// <summary>
/// Lock for id generating
/// </summary>
protected static object idLock = new();
/// <summary>
/// A default serializer
/// </summary>
private static readonly JsonSerializer _defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings
{
DateTimeZoneHandling = DateTimeZoneHandling.Utc,
Culture = CultureInfo.InvariantCulture
});
/// <summary>
/// ctor
/// </summary>
/// <param name="options">Client options</param>
/// <param name="log">Logger</param>
/// <param name="clientOptions">Client options</param>
/// <param name="apiOptions">Api client options</param>
protected BaseApiClient(BaseClientOptions options, ApiClientOptions apiOptions)
protected BaseApiClient(Log log, ClientOptions clientOptions, ApiClientOptions apiOptions)
{
Options = apiOptions;
_apiCredentials = apiOptions.ApiCredentials?.Copy() ?? options.ApiCredentials?.Copy();
_log = log;
_apiCredentials = apiOptions.ApiCredentials?.Copy() ?? clientOptions.ApiCredentials?.Copy();
BaseAddress = apiOptions.BaseAddress;
}
@@ -61,17 +132,223 @@ namespace CryptoExchange.Net
protected abstract AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials);
/// <inheritdoc />
public void SetApiCredentials(ApiCredentials credentials)
public void SetApiCredentials<T>(T credentials) where T : ApiCredentials
{
_apiCredentials = credentials?.Copy();
_created = false;
_authenticationProvider = null;
}
/// <summary>
/// Tries to parse the json data and return a JToken, validating the input not being empty and being valid json
/// </summary>
/// <param name="data">The data to parse</param>
/// <returns></returns>
protected CallResult<JToken> ValidateJson(string data)
{
if (string.IsNullOrEmpty(data))
{
var info = "Empty data object received";
_log.Write(LogLevel.Error, info);
return new CallResult<JToken>(new DeserializeError(info, data));
}
try
{
return new CallResult<JToken>(JToken.Parse(data));
}
catch (JsonReaderException jre)
{
var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}";
return new CallResult<JToken>(new DeserializeError(info, data));
}
catch (JsonSerializationException jse)
{
var info = $"Deserialize JsonSerializationException: {jse.Message}";
return new CallResult<JToken>(new DeserializeError(info, data));
}
catch (Exception ex)
{
var exceptionInfo = ex.ToLogString();
var info = $"Deserialize Unknown Exception: {exceptionInfo}";
return new CallResult<JToken>(new DeserializeError(info, data));
}
}
/// <summary>
/// Deserialize a string into an object
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="data">The data to deserialize</param>
/// <param name="serializer">A specific serializer to use</param>
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
/// <returns></returns>
protected CallResult<T> Deserialize<T>(string data, JsonSerializer? serializer = null, int? requestId = null)
{
var tokenResult = ValidateJson(data);
if (!tokenResult)
{
_log.Write(LogLevel.Error, tokenResult.Error!.Message);
return new CallResult<T>(tokenResult.Error);
}
return Deserialize<T>(tokenResult.Data, serializer, requestId);
}
/// <summary>
/// Deserialize a JToken into an object
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="obj">The data to deserialize</param>
/// <param name="serializer">A specific serializer to use</param>
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
/// <returns></returns>
protected CallResult<T> Deserialize<T>(JToken obj, JsonSerializer? serializer = null, int? requestId = null)
{
serializer ??= _defaultSerializer;
try
{
return new CallResult<T>(obj.ToObject<T>(serializer)!);
}
catch (JsonReaderException jre)
{
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message} Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {obj}";
_log.Write(LogLevel.Error, info);
return new CallResult<T>(new DeserializeError(info, obj));
}
catch (JsonSerializationException jse)
{
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message} data: {obj}";
_log.Write(LogLevel.Error, info);
return new CallResult<T>(new DeserializeError(info, obj));
}
catch (Exception ex)
{
var exceptionInfo = ex.ToLogString();
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {obj}";
_log.Write(LogLevel.Error, info);
return new CallResult<T>(new DeserializeError(info, obj));
}
}
/// <summary>
/// Deserialize a stream into an object
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="stream">The stream to deserialize</param>
/// <param name="serializer">A specific serializer to use</param>
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
/// <param name="elapsedMilliseconds">Milliseconds response time for the request this stream is a response for</param>
/// <returns></returns>
protected async Task<CallResult<T>> DeserializeAsync<T>(Stream stream, JsonSerializer? serializer = null, int? requestId = null, long? elapsedMilliseconds = null)
{
serializer ??= _defaultSerializer;
string? data = null;
try
{
// Let the reader keep the stream open so we're able to seek if needed. The calling method will close the stream.
using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true);
// If we have to output the original json data or output the data into the logging we'll have to read to full response
// in order to log/return the json data
if (Options.OutputOriginalData == true || _log.Level == LogLevel.Trace)
{
data = await reader.ReadToEndAsync().ConfigureAwait(false);
_log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] " : "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms{(_log.Level == LogLevel.Trace ? (": " + data) : "")}");
var result = Deserialize<T>(data, serializer, requestId);
if (Options.OutputOriginalData == true)
result.OriginalData = data;
return result;
}
// If we don't have to keep track of the original json data we can use the JsonTextReader to deserialize the stream directly
// into the desired object, which has increased performance over first reading the string value into memory and deserializing from that
using var jsonReader = new JsonTextReader(reader);
_log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] " : "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms");
return new CallResult<T>(serializer.Deserialize<T>(jsonReader)!);
}
catch (JsonReaderException jre)
{
if (data == null)
{
if (stream.CanSeek)
{
// If we can seek the stream rewind it so we can retrieve the original data that was sent
stream.Seek(0, SeekOrigin.Begin);
data = await ReadStreamAsync(stream).ConfigureAwait(false);
}
else
{
data = "[Data only available in Trace LogLevel]";
}
}
_log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {data}");
return new CallResult<T>(new DeserializeError($"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}", data));
}
catch (JsonSerializationException jse)
{
if (data == null)
{
if (stream.CanSeek)
{
stream.Seek(0, SeekOrigin.Begin);
data = await ReadStreamAsync(stream).ConfigureAwait(false);
}
else
{
data = "[Data only available in Trace LogLevel]";
}
}
_log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message}, data: {data}");
return new CallResult<T>(new DeserializeError($"Deserialize JsonSerializationException: {jse.Message}", data));
}
catch (Exception ex)
{
if (data == null)
{
if (stream.CanSeek)
{
stream.Seek(0, SeekOrigin.Begin);
data = await ReadStreamAsync(stream).ConfigureAwait(false);
}
else
{
data = "[Data only available in Trace LogLevel]";
}
}
var exceptionInfo = ex.ToLogString();
_log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {data}");
return new CallResult<T>(new DeserializeError($"Deserialize Unknown Exception: {exceptionInfo}", data));
}
}
private static async Task<string> ReadStreamAsync(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true);
return await reader.ReadToEndAsync().ConfigureAwait(false);
}
/// <summary>
/// Generate a new unique id. The id is staticly stored so it is guarenteed to be unique across different client instances
/// </summary>
/// <returns></returns>
protected static int NextId()
{
lock (idLock)
{
lastId += 1;
return lastId;
}
}
/// <summary>
/// Dispose
/// </summary>
public void Dispose()
public virtual void Dispose()
{
_disposing = true;
_apiCredentials?.Dispose();
+19 -221
View File
@@ -2,14 +2,8 @@
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace CryptoExchange.Net
{
@@ -30,39 +24,23 @@ namespace CryptoExchange.Net
/// The log object
/// </summary>
protected internal Log log;
/// <summary>
/// The last used id, use NextId() to get the next id and up this
/// </summary>
protected static int lastId;
/// <summary>
/// Lock for id generating
/// </summary>
protected static object idLock = new object();
/// <summary>
/// A default serializer
/// </summary>
private static readonly JsonSerializer defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings
{
DateTimeZoneHandling = DateTimeZoneHandling.Utc,
Culture = CultureInfo.InvariantCulture
});
/// <summary>
/// Provided client options
/// </summary>
public BaseClientOptions ClientOptions { get; }
public ClientOptions ClientOptions { get; }
/// <summary>
/// ctor
/// </summary>
/// <param name="name">The name of the API this client is for</param>
/// <param name="options">The options for this client</param>
protected BaseClient(string name, BaseClientOptions options)
protected BaseClient(string name, ClientOptions options)
{
log = new Log(name);
log.UpdateWriters(options.LogWriters);
log.Level = options.LogLevel;
options.OnLoggingChanged += HandleLogConfigChange;
ClientOptions = options;
@@ -71,6 +49,16 @@ namespace CryptoExchange.Net
log.Write(LogLevel.Trace, $"Client configuration: {options}, CryptoExchange.Net: v{typeof(BaseClient).Assembly.GetName().Version}, {name}.Net: v{GetType().Assembly.GetName().Version}");
}
/// <summary>
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
/// </summary>
/// <param name="credentials">The credentials to set</param>
protected virtual void SetApiCredentials<T>(T credentials) where T : ApiCredentials
{
foreach (var apiClient in ApiClients)
apiClient.SetApiCredentials(credentials);
}
/// <summary>
/// Register an API client
/// </summary>
@@ -83,203 +71,12 @@ namespace CryptoExchange.Net
}
/// <summary>
/// Tries to parse the json data and return a JToken, validating the input not being empty and being valid json
/// Handle a change in the client options log config
/// </summary>
/// <param name="data">The data to parse</param>
/// <returns></returns>
protected CallResult<JToken> ValidateJson(string data)
private void HandleLogConfigChange()
{
if (string.IsNullOrEmpty(data))
{
var info = "Empty data object received";
log.Write(LogLevel.Error, info);
return new CallResult<JToken>(new DeserializeError(info, data));
}
try
{
return new CallResult<JToken>(JToken.Parse(data));
}
catch (JsonReaderException jre)
{
var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}";
return new CallResult<JToken>(new DeserializeError(info, data));
}
catch (JsonSerializationException jse)
{
var info = $"Deserialize JsonSerializationException: {jse.Message}";
return new CallResult<JToken>(new DeserializeError(info, data));
}
catch (Exception ex)
{
var exceptionInfo = ex.ToLogString();
var info = $"Deserialize Unknown Exception: {exceptionInfo}";
return new CallResult<JToken>(new DeserializeError(info, data));
}
}
/// <summary>
/// Deserialize a string into an object
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="data">The data to deserialize</param>
/// <param name="serializer">A specific serializer to use</param>
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
/// <returns></returns>
protected CallResult<T> Deserialize<T>(string data, JsonSerializer? serializer = null, int? requestId = null)
{
var tokenResult = ValidateJson(data);
if (!tokenResult)
{
log.Write(LogLevel.Error, tokenResult.Error!.Message);
return new CallResult<T>( tokenResult.Error);
}
return Deserialize<T>(tokenResult.Data, serializer, requestId);
}
/// <summary>
/// Deserialize a JToken into an object
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="obj">The data to deserialize</param>
/// <param name="serializer">A specific serializer to use</param>
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
/// <returns></returns>
protected CallResult<T> Deserialize<T>(JToken obj, JsonSerializer? serializer = null, int? requestId = null)
{
serializer ??= defaultSerializer;
try
{
return new CallResult<T>(obj.ToObject<T>(serializer)!);
}
catch (JsonReaderException jre)
{
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message} Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {obj}";
log.Write(LogLevel.Error, info);
return new CallResult<T>(new DeserializeError(info, obj));
}
catch (JsonSerializationException jse)
{
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message} data: {obj}";
log.Write(LogLevel.Error, info);
return new CallResult<T>(new DeserializeError(info, obj));
}
catch (Exception ex)
{
var exceptionInfo = ex.ToLogString();
var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {obj}";
log.Write(LogLevel.Error, info);
return new CallResult<T>(new DeserializeError(info, obj));
}
}
/// <summary>
/// Deserialize a stream into an object
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="stream">The stream to deserialize</param>
/// <param name="serializer">A specific serializer to use</param>
/// <param name="requestId">Id of the request the data is returned from (used for grouping logging by request)</param>
/// <param name="elapsedMilliseconds">Milliseconds response time for the request this stream is a response for</param>
/// <returns></returns>
protected async Task<CallResult<T>> DeserializeAsync<T>(Stream stream, JsonSerializer? serializer = null, int? requestId = null, long? elapsedMilliseconds = null)
{
serializer ??= defaultSerializer;
string? data = null;
try
{
// Let the reader keep the stream open so we're able to seek if needed. The calling method will close the stream.
using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true);
// If we have to output the original json data or output the data into the logging we'll have to read to full response
// in order to log/return the json data
if (ClientOptions.OutputOriginalData || log.Level == LogLevel.Trace)
{
data = await reader.ReadToEndAsync().ConfigureAwait(false);
log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] ": "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms{(log.Level == LogLevel.Trace ? (": " + data) : "")}");
var result = Deserialize<T>(data, serializer, requestId);
if(ClientOptions.OutputOriginalData)
result.OriginalData = data;
return result;
}
// If we don't have to keep track of the original json data we can use the JsonTextReader to deserialize the stream directly
// into the desired object, which has increased performance over first reading the string value into memory and deserializing from that
using var jsonReader = new JsonTextReader(reader);
log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] ": "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms");
return new CallResult<T>(serializer.Deserialize<T>(jsonReader)!);
}
catch (JsonReaderException jre)
{
if (data == null)
{
if (stream.CanSeek)
{
// If we can seek the stream rewind it so we can retrieve the original data that was sent
stream.Seek(0, SeekOrigin.Begin);
data = await ReadStreamAsync(stream).ConfigureAwait(false);
}
else
data = "[Data only available in Trace LogLevel]";
}
log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {data}");
return new CallResult<T>(new DeserializeError($"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}", data));
}
catch (JsonSerializationException jse)
{
if (data == null)
{
if (stream.CanSeek)
{
stream.Seek(0, SeekOrigin.Begin);
data = await ReadStreamAsync(stream).ConfigureAwait(false);
}
else
data = "[Data only available in Trace LogLevel]";
}
log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message}, data: {data}");
return new CallResult<T>(new DeserializeError($"Deserialize JsonSerializationException: {jse.Message}", data));
}
catch (Exception ex)
{
if (data == null)
{
if (stream.CanSeek)
{
stream.Seek(0, SeekOrigin.Begin);
data = await ReadStreamAsync(stream).ConfigureAwait(false);
}
else
data = "[Data only available in Trace LogLevel]";
}
var exceptionInfo = ex.ToLogString();
log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {data}");
return new CallResult<T>(new DeserializeError($"Deserialize Unknown Exception: {exceptionInfo}", data));
}
}
private static async Task<string> ReadStreamAsync(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true);
return await reader.ReadToEndAsync().ConfigureAwait(false);
}
/// <summary>
/// Generate a new unique id. The id is staticly stored so it is guarenteed to be unique across different client instances
/// </summary>
/// <returns></returns>
protected static int NextId()
{
lock (idLock)
{
lastId += 1;
return lastId;
}
log.UpdateWriters(ClientOptions.LogWriters);
log.Level = ClientOptions.LogLevel;
}
/// <summary>
@@ -288,6 +85,7 @@ namespace CryptoExchange.Net
public virtual void Dispose()
{
log.Write(LogLevel.Debug, "Disposing client");
ClientOptions.OnLoggingChanged -= HandleLogConfigChange;
foreach (var client in ApiClients)
client.Dispose();
}
+1 -380
View File
@@ -1,19 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Requests;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net
{
@@ -22,386 +11,18 @@ namespace CryptoExchange.Net
/// </summary>
public abstract class BaseRestClient : BaseClient, IRestClient
{
/// <summary>
/// The factory for creating requests. Used for unit testing
/// </summary>
public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
/// <summary>
/// Where to put the parameters for requests with different Http methods
/// </summary>
protected Dictionary<HttpMethod, HttpMethodParameterPosition> ParameterPositions { get; set; } = new Dictionary<HttpMethod, HttpMethodParameterPosition>
{
{ HttpMethod.Get, HttpMethodParameterPosition.InUri },
{ HttpMethod.Post, HttpMethodParameterPosition.InBody },
{ HttpMethod.Delete, HttpMethodParameterPosition.InBody },
{ HttpMethod.Put, HttpMethodParameterPosition.InBody }
};
/// <summary>
/// Request body content type
/// </summary>
protected RequestBodyFormat requestBodyFormat = RequestBodyFormat.Json;
/// <summary>
/// Whether or not we need to manually parse an error instead of relying on the http status code
/// </summary>
protected bool manualParseError = false;
/// <summary>
/// How to serialize array parameters when making requests
/// </summary>
protected ArrayParametersSerialization arraySerialization = ArrayParametersSerialization.Array;
/// <summary>
/// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody)
/// </summary>
protected string requestBodyEmptyContent = "{}";
/// <inheritdoc />
public int TotalRequestsMade => ApiClients.OfType<RestApiClient>().Sum(s => s.TotalRequestsMade);
/// <summary>
/// Request headers to be sent with each request
/// </summary>
protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
/// <summary>
/// Client options
/// </summary>
public new BaseRestClientOptions ClientOptions { get; }
/// <summary>
/// ctor
/// </summary>
/// <param name="name">The name of the API this client is for</param>
/// <param name="options">The options for this client</param>
protected BaseRestClient(string name, BaseRestClientOptions options) : base(name, options)
protected BaseRestClient(string name, ClientOptions options) : base(name, options)
{
if (options == null)
throw new ArgumentNullException(nameof(options));
ClientOptions = options;
RequestFactory.Configure(options.RequestTimeout, options.Proxy, options.HttpClient);
}
/// <inheritdoc />
public void SetApiCredentials(ApiCredentials credentials)
{
foreach (var apiClient in ApiClients)
apiClient.SetApiCredentials(credentials);
}
/// <summary>
/// Execute a request to the uri and deserialize the response into the provided type parameter
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="apiClient">The API client the request is for</param>
/// <param name="uri">The uri to send the request to</param>
/// <param name="method">The method of the request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="signed">Whether or not the request should be authenticated</param>
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
/// <param name="requestWeight">Credits used for the request</param>
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
/// <returns></returns>
[return: NotNull]
protected virtual async Task<WebCallResult<T>> SendRequestAsync<T>(
RestApiClient apiClient,
Uri uri,
HttpMethod method,
CancellationToken cancellationToken,
Dictionary<string, object>? parameters = null,
bool signed = false,
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null,
int requestWeight = 1,
JsonSerializer? deserializer = null,
Dictionary<string, string>? additionalHeaders = null,
bool ignoreRatelimit = false
) where T : class
{
var requestId = NextId();
if (signed)
{
var syncTimeResult = await apiClient.SyncTimeAsync().ConfigureAwait(false);
if (!syncTimeResult)
{
log.Write(LogLevel.Debug, $"[{requestId}] Failed to sync time, aborting request: " + syncTimeResult.Error);
return syncTimeResult.As<T>(default);
}
}
if (!ignoreRatelimit)
{
foreach (var limiter in apiClient.RateLimiters)
{
var limitResult = await limiter.LimitRequestAsync(log, uri.AbsolutePath, method, signed, apiClient.Options.ApiCredentials?.Key, apiClient.Options.RateLimitingBehaviour, requestWeight, cancellationToken).ConfigureAwait(false);
if (!limitResult.Success)
return new WebCallResult<T>(limitResult.Error!);
}
}
if (signed && apiClient.AuthenticationProvider == null)
{
log.Write(LogLevel.Warning, $"[{requestId}] Request {uri.AbsolutePath} failed because no ApiCredentials were provided");
return new WebCallResult<T>(new NoApiCredentialsError());
}
log.Write(LogLevel.Information, $"[{requestId}] Creating request for " + uri);
var paramsPosition = parameterPosition ?? ParameterPositions[method];
var request = ConstructRequest(apiClient, uri, method, parameters, signed, paramsPosition, arraySerialization ?? this.arraySerialization, requestId, additionalHeaders);
string? paramString = "";
if (paramsPosition == HttpMethodParameterPosition.InBody)
paramString = $" with request body '{request.Content}'";
var headers = request.GetHeaders();
if (headers.Any())
paramString += " with headers " + string.Join(", ", headers.Select(h => h.Key + $"=[{string.Join(",", h.Value)}]"));
apiClient.TotalRequestsMade++;
log.Write(LogLevel.Trace, $"[{requestId}] Sending {method}{(signed ? " signed" : "")} request to {request.Uri}{paramString ?? " "}{(ClientOptions.Proxy == null ? "" : $" via proxy {ClientOptions.Proxy.Host}")}");
return await GetResponseAsync<T>(request, deserializer, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Executes the request and returns the result deserialized into the type parameter class
/// </summary>
/// <param name="request">The request object to execute</param>
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns></returns>
protected virtual async Task<WebCallResult<T>> GetResponseAsync<T>(IRequest request, JsonSerializer? deserializer, CancellationToken cancellationToken)
{
try
{
var sw = Stopwatch.StartNew();
var response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false);
sw.Stop();
var statusCode = response.StatusCode;
var headers = response.ResponseHeaders;
var responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
// If we have to manually parse error responses (can't rely on HttpStatusCode) we'll need to read the full
// response before being able to deserialize it into the resulting type since we don't know if it an error response or data
if (manualParseError)
{
using var reader = new StreamReader(responseStream);
var data = await reader.ReadToEndAsync().ConfigureAwait(false);
responseStream.Close();
response.Close();
log.Write(LogLevel.Debug, $"[{request.RequestId}] Response received in {sw.ElapsedMilliseconds}ms{(log.Level == LogLevel.Trace ? (": "+data): "")}");
// Validate if it is valid json. Sometimes other data will be returned, 502 error html pages for example
var parseResult = ValidateJson(data);
if (!parseResult.Success)
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!);
// Let the library implementation see if it is an error response, and if so parse the error
var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false);
if (error != null)
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!);
// Not an error, so continue deserializing
var deserializeResult = Deserialize<T>(parseResult.Data, deserializer, request.RequestId);
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data: null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), deserializeResult.Data, deserializeResult.Error);
}
else
{
// Success status code, and we don't have to check for errors. Continue deserializing directly from the stream
var desResult = await DeserializeAsync<T>(responseStream, deserializer, request.RequestId, sw.ElapsedMilliseconds).ConfigureAwait(false);
responseStream.Close();
response.Close();
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, ClientOptions.OutputOriginalData ? desResult.OriginalData : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), desResult.Data, desResult.Error);
}
}
else
{
// Http status code indicates error
using var reader = new StreamReader(responseStream);
var data = await reader.ReadToEndAsync().ConfigureAwait(false);
log.Write(LogLevel.Warning, $"[{request.RequestId}] Error received in {sw.ElapsedMilliseconds}ms: {data}");
responseStream.Close();
response.Close();
var parseResult = ValidateJson(data);
var error = parseResult.Success ? ParseErrorResponse(parseResult.Data) : parseResult.Error!;
if(error.Code == null || error.Code == 0)
error.Code = (int)response.StatusCode;
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, data, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error);
}
}
catch (HttpRequestException requestException)
{
// Request exception, can't reach server for instance
var exceptionInfo = requestException.ToLogString();
log.Write(LogLevel.Warning, $"[{request.RequestId}] Request exception: " + exceptionInfo);
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError(exceptionInfo));
}
catch (OperationCanceledException canceledException)
{
if (cancellationToken != default && canceledException.CancellationToken == cancellationToken)
{
// Cancellation token canceled by caller
log.Write(LogLevel.Warning, $"[{request.RequestId}] Request canceled by cancellation token");
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new CancellationRequestedError());
}
else
{
// Request timed out
log.Write(LogLevel.Warning, $"[{request.RequestId}] Request timed out: " + canceledException.ToLogString());
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError($"[{request.RequestId}] Request timed out"));
}
}
}
/// <summary>
/// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error.
/// When setting manualParseError to true this method will be called for each response to be able to check if the response is an error or not.
/// If the response is an error this method should return the parsed error, else it should return null
/// </summary>
/// <param name="data">Received data</param>
/// <returns>Null if not an error, Error otherwise</returns>
protected virtual Task<ServerError?> TryParseErrorAsync(JToken data)
{
return Task.FromResult<ServerError?>(null);
}
/// <summary>
/// Creates a request object
/// </summary>
/// <param name="apiClient">The API client the request is for</param>
/// <param name="uri">The uri to send the request to</param>
/// <param name="method">The method of the request</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="signed">Whether or not the request should be authenticated</param>
/// <param name="parameterPosition">Where the parameters should be placed</param>
/// <param name="arraySerialization">How array parameters should be serialized</param>
/// <param name="requestId">Unique id of a request</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <returns></returns>
protected virtual IRequest ConstructRequest(
RestApiClient apiClient,
Uri uri,
HttpMethod method,
Dictionary<string, object>? parameters,
bool signed,
HttpMethodParameterPosition parameterPosition,
ArrayParametersSerialization arraySerialization,
int requestId,
Dictionary<string, string>? additionalHeaders)
{
parameters ??= new Dictionary<string, object>();
for (var i = 0; i< parameters.Count; i++)
{
var kvp = parameters.ElementAt(i);
if (kvp.Value is Func<object> delegateValue)
parameters[kvp.Key] = delegateValue();
}
if (parameterPosition == HttpMethodParameterPosition.InUri)
{
foreach (var parameter in parameters)
uri = uri.AddQueryParmeter(parameter.Key, parameter.Value.ToString());
}
var headers = new Dictionary<string, string>();
var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
if (apiClient.AuthenticationProvider != null)
apiClient.AuthenticationProvider.AuthenticateRequest(
apiClient,
uri,
method,
parameters,
signed,
arraySerialization,
parameterPosition,
out uriParameters,
out bodyParameters,
out headers);
// Sanity check
foreach(var param in parameters)
{
if (!uriParameters.ContainsKey(param.Key) && !bodyParameters.ContainsKey(param.Key))
throw new Exception($"Missing parameter {param.Key} after authentication processing. AuthenticationProvider implementation " +
$"should return provided parameters in either the uri or body parameters output");
}
// Add the auth parameters to the uri, start with a new URI to be able to sort the parameters including the auth parameters
uri = uri.SetParameters(uriParameters, arraySerialization);
var request = RequestFactory.Create(method, uri, requestId);
request.Accept = Constants.JsonContentHeader;
foreach (var header in headers)
request.AddHeader(header.Key, header.Value);
if (additionalHeaders != null)
{
foreach (var header in additionalHeaders)
request.AddHeader(header.Key, header.Value);
}
if (StandardRequestHeaders != null)
{
foreach (var header in StandardRequestHeaders)
// Only add it if it isn't overwritten
if (additionalHeaders?.ContainsKey(header.Key) != true)
request.AddHeader(header.Key, header.Value);
}
if (parameterPosition == HttpMethodParameterPosition.InBody)
{
var contentType = requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
if (bodyParameters.Any())
WriteParamBody(request, bodyParameters, contentType);
else
request.SetContent(requestBodyEmptyContent, contentType);
}
return request;
}
/// <summary>
/// Writes the parameters of the request to the request object body
/// </summary>
/// <param name="request">The request to set the parameters on</param>
/// <param name="parameters">The parameters to set</param>
/// <param name="contentType">The content type of the data</param>
protected virtual void WriteParamBody(IRequest request, SortedDictionary<string, object> parameters, string contentType)
{
if (requestBodyFormat == RequestBodyFormat.Json)
{
// Write the parameters as json in the body
var stringData = JsonConvert.SerializeObject(parameters);
request.SetContent(stringData, contentType);
}
else if (requestBodyFormat == RequestBodyFormat.FormData)
{
// Write the parameters as form data in the body
var stringData = parameters.ToFormData();
request.SetContent(stringData, contentType);
}
}
/// <summary>
/// Parse an error response from the server. Only used when server returns a status other than Success(200)
/// </summary>
/// <param name="error">The string the request returned</param>
/// <returns></returns>
protected virtual Error ParseErrorResponse(JToken error)
{
return new ServerError(error.ToString());
}
}
}
+41 -633
View File
@@ -1,17 +1,13 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Text;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net
{
@@ -21,81 +17,18 @@ namespace CryptoExchange.Net
public abstract class BaseSocketClient: BaseClient, ISocketClient
{
#region fields
/// <summary>
/// The factory for creating sockets. Used for unit testing
/// </summary>
public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory();
/// <summary>
/// List of socket connections currently connecting/connected
/// </summary>
protected internal ConcurrentDictionary<int, SocketConnection> sockets = new();
/// <summary>
/// Semaphore used while creating sockets
/// </summary>
protected internal readonly SemaphoreSlim semaphoreSlim = new(1);
/// <summary>
/// The max amount of concurrent socket connections
/// </summary>
protected int MaxSocketConnections { get; set; } = 9999;
/// <summary>
/// Delegate used for processing byte data received from socket connections before it is processed by handlers
/// </summary>
protected Func<byte[], string>? dataInterpreterBytes;
/// <summary>
/// Delegate used for processing string data received from socket connections before it is processed by handlers
/// </summary>
protected Func<string, string>? dataInterpreterString;
/// <summary>
/// Handlers for data from the socket which doesn't need to be forwarded to the caller. Ping or welcome messages for example.
/// </summary>
protected Dictionary<string, Action<MessageEvent>> genericHandlers = new();
/// <summary>
/// The task that is sending periodic data on the websocket. Can be used for sending Ping messages every x seconds or similair. Not necesarry.
/// </summary>
protected Task? periodicTask;
/// <summary>
/// Wait event for the periodicTask
/// </summary>
protected AsyncResetEvent? periodicEvent;
/// <summary>
/// If client is disposing
/// </summary>
protected bool disposing;
/// <summary>
/// If true; data which is a response to a query will also be distributed to subscriptions
/// If false; data which is a response to a query won't get forwarded to subscriptions as well
/// </summary>
protected internal bool ContinueOnQueryResponse { get; protected set; }
/// <summary>
/// If a message is received on the socket which is not handled by a handler this boolean determines whether this logs an error message
/// </summary>
protected internal bool UnhandledMessageExpected { get; set; }
/// <summary>
/// The max amount of outgoing messages per socket per second
/// </summary>
protected internal int? RateLimitPerSocketPerSecond { get; set; }
/// <inheritdoc />
public double IncomingKbps
{
get
{
if (!sockets.Any())
return 0;
return sockets.Sum(s => s.Value.Socket.IncomingKbps);
}
}
/// <summary>
/// Client options
/// </summary>
public new BaseSocketClientOptions ClientOptions { get; }
public int CurrentConnections => ApiClients.OfType<SocketApiClient>().Sum(c => c.CurrentConnections);
/// <inheritdoc />
public int CurrentSubscriptions => ApiClients.OfType<SocketApiClient>().Sum(s => s.CurrentSubscriptions);
/// <inheritdoc />
public double IncomingKbps => ApiClients.OfType<SocketApiClient>().Sum(s => s.IncomingKbps);
#endregion
/// <summary>
@@ -103,525 +36,8 @@ namespace CryptoExchange.Net
/// </summary>
/// <param name="name">The name of the API this client is for</param>
/// <param name="options">The options for this client</param>
protected BaseSocketClient(string name, BaseSocketClientOptions options) : base(name, options)
protected BaseSocketClient(string name, ClientOptions options) : base(name, options)
{
if (options == null)
throw new ArgumentNullException(nameof(options));
ClientOptions = options;
}
/// <summary>
/// Set a delegate to be used for processing data received from socket connections before it is processed by handlers
/// </summary>
/// <param name="byteHandler">Handler for byte data</param>
/// <param name="stringHandler">Handler for string data</param>
protected void SetDataInterpreter(Func<byte[], string>? byteHandler, Func<string, string>? stringHandler)
{
dataInterpreterBytes = byteHandler;
dataInterpreterString = stringHandler;
}
/// <summary>
/// Connect to an url and listen for data on the BaseAddress
/// </summary>
/// <typeparam name="T">The type of the expected data</typeparam>
/// <param name="apiClient">The API client the subscription is for</param>
/// <param name="request">The optional request object to send, will be serialized to json</param>
/// <param name="identifier">The identifier to use, necessary if no request object is sent</param>
/// <param name="authenticated">If the subscription is to an authenticated endpoint</param>
/// <param name="dataHandler">The handler of update data</param>
/// <param name="ct">Cancellation token for closing this subscription</param>
/// <returns></returns>
protected virtual Task<CallResult<UpdateSubscription>> SubscribeAsync<T>(SocketApiClient apiClient, object? request, string? identifier, bool authenticated, Action<DataEvent<T>> dataHandler, CancellationToken ct)
{
return SubscribeAsync(apiClient, apiClient.Options.BaseAddress, request, identifier, authenticated, dataHandler, ct);
}
/// <summary>
/// Connect to an url and listen for data
/// </summary>
/// <typeparam name="T">The type of the expected data</typeparam>
/// <param name="apiClient">The API client the subscription is for</param>
/// <param name="url">The URL to connect to</param>
/// <param name="request">The optional request object to send, will be serialized to json</param>
/// <param name="identifier">The identifier to use, necessary if no request object is sent</param>
/// <param name="authenticated">If the subscription is to an authenticated endpoint</param>
/// <param name="dataHandler">The handler of update data</param>
/// <param name="ct">Cancellation token for closing this subscription</param>
/// <returns></returns>
protected virtual async Task<CallResult<UpdateSubscription>> SubscribeAsync<T>(SocketApiClient apiClient, string url, object? request, string? identifier, bool authenticated, Action<DataEvent<T>> dataHandler, CancellationToken ct)
{
if (disposing)
return new CallResult<UpdateSubscription>(new InvalidOperationError("Client disposed, can't subscribe"));
SocketConnection socketConnection;
SocketSubscription subscription;
var released = false;
// Wait for a semaphore here, so we only connect 1 socket at a time.
// This is necessary for being able to see if connections can be combined
try
{
await semaphoreSlim.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return new CallResult<UpdateSubscription>(new CancellationRequestedError());
}
try
{
// Get a new or existing socket connection
socketConnection = GetSocketConnection(apiClient, url, authenticated);
// Add a subscription on the socket connection
subscription = AddSubscription(request, identifier, true, socketConnection, dataHandler);
if (ClientOptions.SocketSubscriptionsCombineTarget == 1)
{
// Only 1 subscription per connection, so no need to wait for connection since a new subscription will create a new connection anyway
semaphoreSlim.Release();
released = true;
}
var needsConnecting = !socketConnection.Connected;
var connectResult = await ConnectIfNeededAsync(socketConnection, authenticated).ConfigureAwait(false);
if (!connectResult)
return new CallResult<UpdateSubscription>(connectResult.Error!);
}
finally
{
if(!released)
semaphoreSlim.Release();
}
if (socketConnection.PausedActivity)
{
log.Write(LogLevel.Warning, $"Socket {socketConnection.Socket.Id} has been paused, can't subscribe at this moment");
return new CallResult<UpdateSubscription>( new ServerError("Socket is paused"));
}
if (request != null)
{
// Send the request and wait for answer
var subResult = await SubscribeAndWaitAsync(socketConnection, request, subscription).ConfigureAwait(false);
if (!subResult)
{
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
return new CallResult<UpdateSubscription>(subResult.Error!);
}
}
else
{
// No request to be sent, so just mark the subscription as comfirmed
subscription.Confirmed = true;
}
socketConnection.ShouldReconnect = true;
if (ct != default)
{
subscription.CancellationTokenRegistration = ct.Register(async () =>
{
log.Write(LogLevel.Information, $"Socket {socketConnection.Socket.Id} Cancellation token set, closing subscription");
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
}, false);
}
log.Write(LogLevel.Information, $"Socket {socketConnection.Socket.Id} subscription completed");
return new CallResult<UpdateSubscription>(new UpdateSubscription(socketConnection, subscription));
}
/// <summary>
/// Sends the subscribe request and waits for a response to that request
/// </summary>
/// <param name="socketConnection">The connection to send the request on</param>
/// <param name="request">The request to send, will be serialized to json</param>
/// <param name="subscription">The subscription the request is for</param>
/// <returns></returns>
protected internal virtual async Task<CallResult<bool>> SubscribeAndWaitAsync(SocketConnection socketConnection, object request, SocketSubscription subscription)
{
CallResult<object>? callResult = null;
await socketConnection.SendAndWaitAsync(request, ClientOptions.SocketResponseTimeout, data => HandleSubscriptionResponse(socketConnection, subscription, request, data, out callResult)).ConfigureAwait(false);
if (callResult?.Success == true)
{
subscription.Confirmed = true;
return new CallResult<bool>(true);
}
if(callResult== null)
return new CallResult<bool>(new ServerError("No response on subscription request received"));
return new CallResult<bool>(callResult.Error!);
}
/// <summary>
/// Send a query on a socket connection to the BaseAddress and wait for the response
/// </summary>
/// <typeparam name="T">Expected result type</typeparam>
/// <param name="apiClient">The API client the query is for</param>
/// <param name="request">The request to send, will be serialized to json</param>
/// <param name="authenticated">If the query is to an authenticated endpoint</param>
/// <returns></returns>
protected virtual Task<CallResult<T>> QueryAsync<T>(SocketApiClient apiClient, object request, bool authenticated)
{
return QueryAsync<T>(apiClient, apiClient.Options.BaseAddress, request, authenticated);
}
/// <summary>
/// Send a query on a socket connection and wait for the response
/// </summary>
/// <typeparam name="T">The expected result type</typeparam>
/// <param name="apiClient">The API client the query is for</param>
/// <param name="url">The url for the request</param>
/// <param name="request">The request to send</param>
/// <param name="authenticated">Whether the socket should be authenticated</param>
/// <returns></returns>
protected virtual async Task<CallResult<T>> QueryAsync<T>(SocketApiClient apiClient, string url, object request, bool authenticated)
{
if (disposing)
return new CallResult<T>(new InvalidOperationError("Client disposed, can't query"));
SocketConnection socketConnection;
var released = false;
await semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
socketConnection = GetSocketConnection(apiClient, url, authenticated);
if (ClientOptions.SocketSubscriptionsCombineTarget == 1)
{
// Can release early when only a single sub per connection
semaphoreSlim.Release();
released = true;
}
var connectResult = await ConnectIfNeededAsync(socketConnection, authenticated).ConfigureAwait(false);
if (!connectResult)
return new CallResult<T>(connectResult.Error!);
}
finally
{
//When the task is ready, release the semaphore. It is vital to ALWAYS release the semaphore when we are ready, or else we will end up with a Semaphore that is forever locked.
//This is why it is important to do the Release within a try...finally clause; program execution may crash or take a different path, this way you are guaranteed execution
if (!released)
semaphoreSlim.Release();
}
if (socketConnection.PausedActivity)
{
log.Write(LogLevel.Warning, $"Socket {socketConnection.Socket.Id} has been paused, can't send query at this moment");
return new CallResult<T>(new ServerError("Socket is paused"));
}
return await QueryAndWaitAsync<T>(socketConnection, request).ConfigureAwait(false);
}
/// <summary>
/// Sends the query request and waits for the result
/// </summary>
/// <typeparam name="T">The expected result type</typeparam>
/// <param name="socket">The connection to send and wait on</param>
/// <param name="request">The request to send</param>
/// <returns></returns>
protected virtual async Task<CallResult<T>> QueryAndWaitAsync<T>(SocketConnection socket, object request)
{
var dataResult = new CallResult<T>(new ServerError("No response on query received"));
await socket.SendAndWaitAsync(request, ClientOptions.SocketResponseTimeout, data =>
{
if (!HandleQueryResponse<T>(socket, request, data, out var callResult))
return false;
dataResult = callResult;
return true;
}).ConfigureAwait(false);
return dataResult;
}
/// <summary>
/// Checks if a socket needs to be connected and does so if needed. Also authenticates on the socket if needed
/// </summary>
/// <param name="socket">The connection to check</param>
/// <param name="authenticated">Whether the socket should authenticated</param>
/// <returns></returns>
protected virtual async Task<CallResult<bool>> ConnectIfNeededAsync(SocketConnection socket, bool authenticated)
{
if (socket.Connected)
return new CallResult<bool>(true);
var connectResult = await ConnectSocketAsync(socket).ConfigureAwait(false);
if (!connectResult)
return new CallResult<bool>(connectResult.Error!);
if (!authenticated || socket.Authenticated)
return new CallResult<bool>(true);
var result = await AuthenticateSocketAsync(socket).ConfigureAwait(false);
if (!result)
{
await socket.CloseAsync().ConfigureAwait(false);
log.Write(LogLevel.Warning, $"Socket {socket.Socket.Id} authentication failed");
result.Error!.Message = "Authentication failed: " + result.Error.Message;
return new CallResult<bool>(result.Error);
}
socket.Authenticated = true;
return new CallResult<bool>(true);
}
/// <summary>
/// The socketConnection received data (the data JToken parameter). The implementation of this method should check if the received data is a response to the query that was send (the request parameter).
/// For example; A query is sent in a request message with an Id parameter with value 10. The socket receives data and calls this method to see if the data it received is an
/// anwser to any query that was done. The implementation of this method should check if the response.Id == request.Id to see if they match (assuming the api has some sort of Id tracking on messages,
/// if not some other method has be implemented to match the messages).
/// If the messages match, the callResult out parameter should be set with the deserialized data in the from of (T) and return true.
/// </summary>
/// <typeparam name="T">The type of response that is expected on the query</typeparam>
/// <param name="socketConnection">The socket connection</param>
/// <param name="request">The request that a response is awaited for</param>
/// <param name="data">The message received from the server</param>
/// <param name="callResult">The interpretation (null if message wasn't a response to the request)</param>
/// <returns>True if the message was a response to the query</returns>
protected internal abstract bool HandleQueryResponse<T>(SocketConnection socketConnection, object request, JToken data, [NotNullWhen(true)]out CallResult<T>? callResult);
/// <summary>
/// The socketConnection received data (the data JToken parameter). The implementation of this method should check if the received data is a response to the subscription request that was send (the request parameter).
/// For example; A subscribe request message is send with an Id parameter with value 10. The socket receives data and calls this method to see if the data it received is an
/// anwser to any subscription request that was done. The implementation of this method should check if the response.Id == request.Id to see if they match (assuming the api has some sort of Id tracking on messages,
/// if not some other method has be implemented to match the messages).
/// If the messages match, the callResult out parameter should be set with the deserialized data in the from of (T) and return true.
/// </summary>
/// <param name="socketConnection">The socket connection</param>
/// <param name="subscription">A subscription that waiting for a subscription response</param>
/// <param name="request">The request that the subscription sent</param>
/// <param name="data">The message received from the server</param>
/// <param name="callResult">The interpretation (null if message wasn't a response to the request)</param>
/// <returns>True if the message was a response to the subscription request</returns>
protected internal abstract bool HandleSubscriptionResponse(SocketConnection socketConnection, SocketSubscription subscription, object request, JToken data, out CallResult<object>? callResult);
/// <summary>
/// Needs to check if a received message matches a handler by request. After subscribing data message will come in. These data messages need to be matched to a specific connection
/// to pass the correct data to the correct handler. The implementation of this method should check if the message received matches the subscribe request that was sent.
/// </summary>
/// <param name="socketConnection">The socket connection the message was recieved on</param>
/// <param name="message">The received data</param>
/// <param name="request">The subscription request</param>
/// <returns>True if the message is for the subscription which sent the request</returns>
protected internal abstract bool MessageMatchesHandler(SocketConnection socketConnection, JToken message, object request);
/// <summary>
/// Needs to check if a received message matches a handler by identifier. Generally used by GenericHandlers. For example; a generic handler is registered which handles ping messages
/// from the server. This method should check if the message received is a ping message and the identifer is the identifier of the GenericHandler
/// </summary>
/// <param name="socketConnection">The socket connection the message was recieved on</param>
/// <param name="message">The received data</param>
/// <param name="identifier">The string identifier of the handler</param>
/// <returns>True if the message is for the handler which has the identifier</returns>
protected internal abstract bool MessageMatchesHandler(SocketConnection socketConnection, JToken message, string identifier);
/// <summary>
/// Needs to authenticate the socket so authenticated queries/subscriptions can be made on this socket connection
/// </summary>
/// <param name="socketConnection">The socket connection that should be authenticated</param>
/// <returns></returns>
protected internal abstract Task<CallResult<bool>> AuthenticateSocketAsync(SocketConnection socketConnection);
/// <summary>
/// Needs to unsubscribe a subscription, typically by sending an unsubscribe request. If multiple subscriptions per socket is not allowed this can just return since the socket will be closed anyway
/// </summary>
/// <param name="connection">The connection on which to unsubscribe</param>
/// <param name="subscriptionToUnsub">The subscription to unsubscribe</param>
/// <returns></returns>
protected internal abstract Task<bool> UnsubscribeAsync(SocketConnection connection, SocketSubscription subscriptionToUnsub);
/// <summary>
/// Optional handler to interpolate data before sending it to the handlers
/// </summary>
/// <param name="message"></param>
/// <returns></returns>
protected internal virtual JToken ProcessTokenData(JToken message)
{
return message;
}
/// <summary>
/// Add a subscription to a connection
/// </summary>
/// <typeparam name="T">The type of data the subscription expects</typeparam>
/// <param name="request">The request of the subscription</param>
/// <param name="identifier">The identifier of the subscription (can be null if request param is used)</param>
/// <param name="userSubscription">Whether or not this is a user subscription (counts towards the max amount of handlers on a socket)</param>
/// <param name="connection">The socket connection the handler is on</param>
/// <param name="dataHandler">The handler of the data received</param>
/// <returns></returns>
protected virtual SocketSubscription AddSubscription<T>(object? request, string? identifier, bool userSubscription, SocketConnection connection, Action<DataEvent<T>> dataHandler)
{
void InternalHandler(MessageEvent messageEvent)
{
if (typeof(T) == typeof(string))
{
var stringData = (T)Convert.ChangeType(messageEvent.JsonData.ToString(), typeof(T));
dataHandler(new DataEvent<T>(stringData, null, ClientOptions.OutputOriginalData ? messageEvent.OriginalData : null, messageEvent.ReceivedTimestamp));
return;
}
var desResult = Deserialize<T>(messageEvent.JsonData);
if (!desResult)
{
log.Write(LogLevel.Warning, $"Socket {connection.Socket.Id} Failed to deserialize data into type {typeof(T)}: {desResult.Error}");
return;
}
dataHandler(new DataEvent<T>(desResult.Data, null, ClientOptions.OutputOriginalData ? messageEvent.OriginalData : null, messageEvent.ReceivedTimestamp));
}
var subscription = request == null
? SocketSubscription.CreateForIdentifier(NextId(), identifier!, userSubscription, InternalHandler)
: SocketSubscription.CreateForRequest(NextId(), request, userSubscription, InternalHandler);
connection.AddSubscription(subscription);
return subscription;
}
/// <summary>
/// Adds a generic message handler. Used for example to reply to ping requests
/// </summary>
/// <param name="identifier">The name of the request handler. Needs to be unique</param>
/// <param name="action">The action to execute when receiving a message for this handler (checked by <see cref="MessageMatchesHandler(SocketConnection, Newtonsoft.Json.Linq.JToken,string)"/>)</param>
protected void AddGenericHandler(string identifier, Action<MessageEvent> action)
{
genericHandlers.Add(identifier, action);
var subscription = SocketSubscription.CreateForIdentifier(NextId(), identifier, false, action);
foreach (var connection in sockets.Values)
connection.AddSubscription(subscription);
}
/// <summary>
/// Gets a connection for a new subscription or query. Can be an existing if there are open position or a new one.
/// </summary>
/// <param name="apiClient">The API client the connection is for</param>
/// <param name="address">The address the socket is for</param>
/// <param name="authenticated">Whether the socket should be authenticated</param>
/// <returns></returns>
protected virtual SocketConnection GetSocketConnection(SocketApiClient apiClient, string address, bool authenticated)
{
var socketResult = sockets.Where(s => s.Value.Socket.Url.TrimEnd('/') == address.TrimEnd('/')
&& (s.Value.ApiClient.GetType() == apiClient.GetType())
&& (s.Value.Authenticated == authenticated || !authenticated) && s.Value.Connected).OrderBy(s => s.Value.SubscriptionCount).FirstOrDefault();
var result = socketResult.Equals(default(KeyValuePair<int, SocketConnection>)) ? null : socketResult.Value;
if (result != null)
{
if (result.SubscriptionCount < ClientOptions.SocketSubscriptionsCombineTarget || (sockets.Count >= MaxSocketConnections && sockets.All(s => s.Value.SubscriptionCount >= ClientOptions.SocketSubscriptionsCombineTarget)))
{
// Use existing socket if it has less than target connections OR it has the least connections and we can't make new
return result;
}
}
// Create new socket
var socket = CreateSocket(address);
var socketConnection = new SocketConnection(this, apiClient, socket);
socketConnection.UnhandledMessage += HandleUnhandledMessage;
foreach (var kvp in genericHandlers)
{
var handler = SocketSubscription.CreateForIdentifier(NextId(), kvp.Key, false, kvp.Value);
socketConnection.AddSubscription(handler);
}
return socketConnection;
}
/// <summary>
/// Process an unhandled message
/// </summary>
/// <param name="token">The token that wasn't processed</param>
protected virtual void HandleUnhandledMessage(JToken token)
{
}
/// <summary>
/// Connect a socket
/// </summary>
/// <param name="socketConnection">The socket to connect</param>
/// <returns></returns>
protected virtual async Task<CallResult<bool>> ConnectSocketAsync(SocketConnection socketConnection)
{
if (await socketConnection.Socket.ConnectAsync().ConfigureAwait(false))
{
sockets.TryAdd(socketConnection.Socket.Id, socketConnection);
return new CallResult<bool>(true);
}
socketConnection.Socket.Dispose();
return new CallResult<bool>(new CantConnectError());
}
/// <summary>
/// Create a socket for an address
/// </summary>
/// <param name="address">The address the socket should connect to</param>
/// <returns></returns>
protected virtual IWebsocket CreateSocket(string address)
{
var socket = SocketFactory.CreateWebsocket(log, address);
log.Write(LogLevel.Debug, $"Socket {socket.Id} new socket created for " + address);
if (ClientOptions.Proxy != null)
socket.SetProxy(ClientOptions.Proxy);
socket.Timeout = ClientOptions.SocketNoDataTimeout;
socket.DataInterpreterBytes = dataInterpreterBytes;
socket.DataInterpreterString = dataInterpreterString;
socket.RatelimitPerSecond = RateLimitPerSocketPerSecond;
socket.OnError += e =>
{
if(e is WebSocketException wse)
log.Write(LogLevel.Warning, $"Socket {socket.Id} error: Websocket error code {wse.WebSocketErrorCode}, details: " + e.ToLogString());
else
log.Write(LogLevel.Warning, $"Socket {socket.Id} error: " + e.ToLogString());
};
return socket;
}
/// <summary>
/// Periodically sends data over a socket connection
/// </summary>
/// <param name="identifier">Identifier for the periodic send</param>
/// <param name="interval">How often</param>
/// <param name="objGetter">Method returning the object to send</param>
public virtual void SendPeriodic(string identifier, TimeSpan interval, Func<SocketConnection, object> objGetter)
{
if (objGetter == null)
throw new ArgumentNullException(nameof(objGetter));
periodicEvent = new AsyncResetEvent();
periodicTask = Task.Run(async () =>
{
while (!disposing)
{
await periodicEvent.WaitAsync(interval).ConfigureAwait(false);
if (disposing)
break;
foreach (var socket in sockets.Values)
{
if (disposing)
break;
if (!socket.Socket.IsOpen)
continue;
var obj = objGetter(socket);
if (obj == null)
continue;
log.Write(LogLevel.Trace, $"Socket {socket.Socket.Id} sending periodic {identifier}");
try
{
socket.Send(obj);
}
catch (Exception ex)
{
log.Write(LogLevel.Warning, $"Socket {socket.Socket.Id} Periodic send {identifier} failed: " + ex.ToLogString());
}
}
}
});
}
/// <summary>
@@ -631,24 +47,12 @@ namespace CryptoExchange.Net
/// <returns></returns>
public virtual async Task UnsubscribeAsync(int subscriptionId)
{
SocketSubscription? subscription = null;
SocketConnection? connection = null;
foreach(var socket in sockets.Values.ToList())
foreach(var socket in ApiClients.OfType<SocketApiClient>())
{
subscription = socket.GetSubscription(subscriptionId);
if (subscription != null)
{
connection = socket;
break;
}
var result = await socket.UnsubscribeAsync(subscriptionId).ConfigureAwait(false);
if (result)
break;
}
if (subscription == null || connection == null)
return;
log.Write(LogLevel.Information, "Closing subscription " + subscriptionId);
await connection.CloseAsync(subscription).ConfigureAwait(false);
}
/// <summary>
@@ -661,7 +65,7 @@ namespace CryptoExchange.Net
if (subscription == null)
throw new ArgumentNullException(nameof(subscription));
log.Write(LogLevel.Information, "Closing subscription " + subscription.Id);
log.Write(LogLevel.Information, $"Socket {subscription.SocketId} Unsubscribing subscription " + subscription.Id);
await subscription.CloseAsync().ConfigureAwait(false);
}
@@ -671,33 +75,37 @@ namespace CryptoExchange.Net
/// <returns></returns>
public virtual async Task UnsubscribeAllAsync()
{
log.Write(LogLevel.Information, $"Closing all {sockets.Sum(s => s.Value.SubscriptionCount)} subscriptions");
await Task.Run(async () =>
{
var tasks = new List<Task>();
{
var socketList = sockets.Values;
foreach (var sub in socketList)
tasks.Add(sub.CloseAsync());
}
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}).ConfigureAwait(false);
var tasks = new List<Task>();
foreach (var client in ApiClients.OfType<SocketApiClient>())
tasks.Add(client.UnsubscribeAllAsync());
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
/// <summary>
/// Dispose the client
/// Reconnect all connections
/// </summary>
public override void Dispose()
/// <returns></returns>
public virtual async Task ReconnectAsync()
{
disposing = true;
periodicEvent?.Set();
periodicEvent?.Dispose();
log.Write(LogLevel.Debug, "Disposing socket client, closing all subscriptions");
Task.Run(UnsubscribeAllAsync).ConfigureAwait(false).GetAwaiter().GetResult();
semaphoreSlim?.Dispose();
base.Dispose();
log.Write(LogLevel.Information, $"Reconnecting all {CurrentConnections} connections");
var tasks = new List<Task>();
foreach (var client in ApiClients.OfType<SocketApiClient>())
{
tasks.Add(client.ReconnectAsync());
}
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
/// <summary>
/// Log the current state of connections and subscriptions
/// </summary>
public string GetSubscriptionsState()
{
var result = new StringBuilder();
foreach(var client in ApiClients.OfType<SocketApiClient>())
result.AppendLine(client.GetSubscriptionsState());
return result.ToString();
}
}
}
+499 -18
View File
@@ -1,35 +1,42 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Requests;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net
{
/// <summary>
/// Base rest API client for interacting with a REST API
/// </summary>
public abstract class RestApiClient: BaseApiClient
public abstract class RestApiClient : BaseApiClient, IRestApiClient
{
/// <summary>
/// Get time sync info for an API client
/// </summary>
/// <returns></returns>
protected abstract TimeSyncInfo GetTimeSyncInfo();
/// <summary>
/// Get time offset for an API client
/// </summary>
/// <returns></returns>
public abstract TimeSpan GetTimeOffset();
/// <inheritdoc />
public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
/// <inheritdoc />
public abstract TimeSyncInfo? GetTimeSyncInfo();
/// <inheritdoc />
public abstract TimeSpan? GetTimeOffset();
/// <inheritdoc />
public int TotalRequestsMade { get; set; }
/// <summary>
/// Total amount of requests made with this API client
/// Request headers to be sent with each request
/// </summary>
public int TotalRequestsMade { get; set; }
protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
/// <summary>
/// Options for this client
@@ -41,28 +48,502 @@ namespace CryptoExchange.Net
/// </summary>
internal IEnumerable<IRateLimiter> RateLimiters { get; }
/// <summary>
/// Options
/// </summary>
internal ClientOptions ClientOptions { get; set; }
/// <summary>
/// ctor
/// </summary>
/// <param name="log">Logger</param>
/// <param name="options">The base client options</param>
/// <param name="apiOptions">The Api client options</param>
public RestApiClient(BaseRestClientOptions options, RestApiClientOptions apiOptions): base(options, apiOptions)
public RestApiClient(Log log, ClientOptions options, RestApiClientOptions apiOptions) : base(log, options, apiOptions)
{
var rateLimiters = new List<IRateLimiter>();
foreach (var rateLimiter in apiOptions.RateLimiters)
rateLimiters.Add(rateLimiter);
RateLimiters = rateLimiters;
ClientOptions = options;
RequestFactory.Configure(apiOptions.RequestTimeout, options.Proxy, apiOptions.HttpClient);
}
/// <summary>
/// Execute a request to the uri and returns if it was successful
/// </summary>
/// <param name="uri">The uri to send the request to</param>
/// <param name="method">The method of the request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="signed">Whether or not the request should be authenticated</param>
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
/// <param name="requestWeight">Credits used for the request</param>
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
/// <returns></returns>
[return: NotNull]
protected virtual async Task<WebCallResult> SendRequestAsync(
Uri uri,
HttpMethod method,
CancellationToken cancellationToken,
Dictionary<string, object>? parameters = null,
bool signed = false,
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null,
int requestWeight = 1,
JsonSerializer? deserializer = null,
Dictionary<string, string>? additionalHeaders = null,
bool ignoreRatelimit = false)
{
int currentTry = 0;
while (true)
{
currentTry++;
var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
if (!request)
return new WebCallResult(request.Error!);
var result = await GetResponseAsync<object>(request.Data, deserializer, cancellationToken, true).ConfigureAwait(false);
if (await ShouldRetryRequestAsync(result, currentTry).ConfigureAwait(false))
continue;
return result.AsDataless();
}
}
/// <summary>
/// Execute a request to the uri and deserialize the response into the provided type parameter
/// </summary>
/// <typeparam name="T">The type to deserialize into</typeparam>
/// <param name="uri">The uri to send the request to</param>
/// <param name="method">The method of the request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="signed">Whether or not the request should be authenticated</param>
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
/// <param name="requestWeight">Credits used for the request</param>
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
/// <returns></returns>
[return: NotNull]
protected virtual async Task<WebCallResult<T>> SendRequestAsync<T>(
Uri uri,
HttpMethod method,
CancellationToken cancellationToken,
Dictionary<string, object>? parameters = null,
bool signed = false,
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null,
int requestWeight = 1,
JsonSerializer? deserializer = null,
Dictionary<string, string>? additionalHeaders = null,
bool ignoreRatelimit = false
) where T : class
{
int currentTry = 0;
while (true)
{
currentTry++;
var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
if (!request)
return new WebCallResult<T>(request.Error!);
var result = await GetResponseAsync<T>(request.Data, deserializer, cancellationToken, false).ConfigureAwait(false);
if (await ShouldRetryRequestAsync(result, currentTry).ConfigureAwait(false))
continue;
return result;
}
}
/// <summary>
/// Prepares a request to be sent to the server
/// </summary>
/// <param name="uri">The uri to send the request to</param>
/// <param name="method">The method of the request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="signed">Whether or not the request should be authenticated</param>
/// <param name="parameterPosition">Where the parameters should be placed, overwrites the value set in the client</param>
/// <param name="arraySerialization">How array parameters should be serialized, overwrites the value set in the client</param>
/// <param name="requestWeight">Credits used for the request</param>
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <param name="ignoreRatelimit">Ignore rate limits for this request</param>
/// <returns></returns>
protected virtual async Task<CallResult<IRequest>> PrepareRequestAsync(
Uri uri,
HttpMethod method,
CancellationToken cancellationToken,
Dictionary<string, object>? parameters = null,
bool signed = false,
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null,
int requestWeight = 1,
JsonSerializer? deserializer = null,
Dictionary<string, string>? additionalHeaders = null,
bool ignoreRatelimit = false)
{
var requestId = NextId();
if (signed)
{
var syncTask = SyncTimeAsync();
var timeSyncInfo = GetTimeSyncInfo();
if (timeSyncInfo != null && timeSyncInfo.TimeSyncState.LastSyncTime == default)
{
// Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue
var syncTimeResult = await syncTask.ConfigureAwait(false);
if (!syncTimeResult)
{
_log.Write(LogLevel.Debug, $"[{requestId}] Failed to sync time, aborting request: " + syncTimeResult.Error);
return syncTimeResult.As<IRequest>(default);
}
}
}
if (!ignoreRatelimit)
{
foreach (var limiter in RateLimiters)
{
var limitResult = await limiter.LimitRequestAsync(_log, uri.AbsolutePath, method, signed, Options.ApiCredentials?.Key, Options.RateLimitingBehaviour, requestWeight, cancellationToken).ConfigureAwait(false);
if (!limitResult.Success)
return new CallResult<IRequest>(limitResult.Error!);
}
}
if (signed && AuthenticationProvider == null)
{
_log.Write(LogLevel.Warning, $"[{requestId}] Request {uri.AbsolutePath} failed because no ApiCredentials were provided");
return new CallResult<IRequest>(new NoApiCredentialsError());
}
_log.Write(LogLevel.Information, $"[{requestId}] Creating request for " + uri);
var paramsPosition = parameterPosition ?? ParameterPositions[method];
var request = ConstructRequest(uri, method, parameters?.OrderBy(p => p.Key).ToDictionary(p => p.Key, p => p.Value), signed, paramsPosition, arraySerialization ?? this.arraySerialization, requestId, additionalHeaders);
string? paramString = "";
if (paramsPosition == HttpMethodParameterPosition.InBody)
paramString = $" with request body '{request.Content}'";
var headers = request.GetHeaders();
if (headers.Any())
paramString += " with headers " + string.Join(", ", headers.Select(h => h.Key + $"=[{string.Join(",", h.Value)}]"));
TotalRequestsMade++;
_log.Write(LogLevel.Trace, $"[{requestId}] Sending {method}{(signed ? " signed" : "")} request to {request.Uri}{paramString ?? " "}{(ClientOptions.Proxy == null ? "" : $" via proxy {ClientOptions.Proxy.Host}")}");
return new CallResult<IRequest>(request);
}
/// <summary>
/// Executes the request and returns the result deserialized into the type parameter class
/// </summary>
/// <param name="request">The request object to execute</param>
/// <param name="deserializer">The JsonSerializer to use for deserialization</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="expectedEmptyResponse">If an empty response is expected</param>
/// <returns></returns>
protected virtual async Task<WebCallResult<T>> GetResponseAsync<T>(
IRequest request,
JsonSerializer? deserializer,
CancellationToken cancellationToken,
bool expectedEmptyResponse)
{
try
{
var sw = Stopwatch.StartNew();
var response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false);
sw.Stop();
var statusCode = response.StatusCode;
var headers = response.ResponseHeaders;
var responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
// If we have to manually parse error responses (can't rely on HttpStatusCode) we'll need to read the full
// response before being able to deserialize it into the resulting type since we don't know if it an error response or data
if (manualParseError)
{
using var reader = new StreamReader(responseStream);
var data = await reader.ReadToEndAsync().ConfigureAwait(false);
responseStream.Close();
response.Close();
_log.Write(LogLevel.Debug, $"[{request.RequestId}] Response received in {sw.ElapsedMilliseconds}ms{(_log.Level == LogLevel.Trace ? (": " + data) : "")}");
if (!expectedEmptyResponse)
{
// Validate if it is valid json. Sometimes other data will be returned, 502 error html pages for example
var parseResult = ValidateJson(data);
if (!parseResult.Success)
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!);
// Let the library implementation see if it is an error response, and if so parse the error
var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false);
if (error != null)
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!);
// Not an error, so continue deserializing
var deserializeResult = Deserialize<T>(parseResult.Data, deserializer, request.RequestId);
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), deserializeResult.Data, deserializeResult.Error);
}
else
{
if (!string.IsNullOrEmpty(data))
{
var parseResult = ValidateJson(data);
if (!parseResult.Success)
// Not empty, and not json
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!);
var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false);
if (error != null)
// Error response
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!);
}
// Empty success response; okay
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, default);
}
}
else
{
if (expectedEmptyResponse)
{
// We expected an empty response and the request is successful and don't manually parse errors, so assume it's correct
responseStream.Close();
response.Close();
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, null);
}
// Success status code, and we don't have to check for errors. Continue deserializing directly from the stream
var desResult = await DeserializeAsync<T>(responseStream, deserializer, request.RequestId, sw.ElapsedMilliseconds).ConfigureAwait(false);
responseStream.Close();
response.Close();
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, Options.OutputOriginalData ? desResult.OriginalData : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), desResult.Data, desResult.Error);
}
}
else
{
// Http status code indicates error
using var reader = new StreamReader(responseStream);
var data = await reader.ReadToEndAsync().ConfigureAwait(false);
_log.Write(LogLevel.Warning, $"[{request.RequestId}] Error received in {sw.ElapsedMilliseconds}ms: {data}");
responseStream.Close();
response.Close();
var parseResult = ValidateJson(data);
var error = parseResult.Success ? ParseErrorResponse(parseResult.Data) : new ServerError(data)!;
if (error.Code == null || error.Code == 0)
error.Code = (int)response.StatusCode;
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, data, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error);
}
}
catch (HttpRequestException requestException)
{
// Request exception, can't reach server for instance
var exceptionInfo = requestException.ToLogString();
_log.Write(LogLevel.Warning, $"[{request.RequestId}] Request exception: " + exceptionInfo);
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError(exceptionInfo));
}
catch (OperationCanceledException canceledException)
{
if (cancellationToken != default && canceledException.CancellationToken == cancellationToken)
{
// Cancellation token canceled by caller
_log.Write(LogLevel.Warning, $"[{request.RequestId}] Request canceled by cancellation token");
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new CancellationRequestedError());
}
else
{
// Request timed out
_log.Write(LogLevel.Warning, $"[{request.RequestId}] Request timed out: " + canceledException.ToLogString());
return new WebCallResult<T>(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError($"[{request.RequestId}] Request timed out"));
}
}
}
/// <summary>
/// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error.
/// When setting manualParseError to true this method will be called for each response to be able to check if the response is an error or not.
/// If the response is an error this method should return the parsed error, else it should return null
/// </summary>
/// <param name="data">Received data</param>
/// <returns>Null if not an error, Error otherwise</returns>
protected virtual Task<ServerError?> TryParseErrorAsync(JToken data)
{
return Task.FromResult<ServerError?>(null);
}
/// <summary>
/// Can be used to indicate that a request should be retried. Defaults to false. Make sure to retry a max number of times (based on the the tries parameter) or the request will retry forever.
/// Note that this is always called; even when the request might be successful
/// </summary>
/// <typeparam name="T">WebCallResult type parameter</typeparam>
/// <param name="callResult">The result of the call</param>
/// <param name="tries">The current try number</param>
/// <returns>True if call should retry, false if the call should return</returns>
protected virtual Task<bool> ShouldRetryRequestAsync<T>(WebCallResult<T> callResult, int tries) => Task.FromResult(false);
/// <summary>
/// Creates a request object
/// </summary>
/// <param name="uri">The uri to send the request to</param>
/// <param name="method">The method of the request</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="signed">Whether or not the request should be authenticated</param>
/// <param name="parameterPosition">Where the parameters should be placed</param>
/// <param name="arraySerialization">How array parameters should be serialized</param>
/// <param name="requestId">Unique id of a request</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <returns></returns>
protected virtual IRequest ConstructRequest(
Uri uri,
HttpMethod method,
Dictionary<string, object>? parameters,
bool signed,
HttpMethodParameterPosition parameterPosition,
ArrayParametersSerialization arraySerialization,
int requestId,
Dictionary<string, string>? additionalHeaders)
{
parameters ??= new Dictionary<string, object>();
for (var i = 0; i < parameters.Count; i++)
{
var kvp = parameters.ElementAt(i);
if (kvp.Value is Func<object> delegateValue)
parameters[kvp.Key] = delegateValue();
}
if (parameterPosition == HttpMethodParameterPosition.InUri)
{
foreach (var parameter in parameters)
uri = uri.AddQueryParmeter(parameter.Key, parameter.Value.ToString());
}
var headers = new Dictionary<string, string>();
var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
if (AuthenticationProvider != null)
{
try
{
AuthenticationProvider.AuthenticateRequest(
this,
uri,
method,
parameters,
signed,
arraySerialization,
parameterPosition,
out uriParameters,
out bodyParameters,
out headers);
}
catch (Exception ex)
{
throw new Exception("Failed to authenticate request, make sure your API credentials are correct", ex);
}
}
// Sanity check
foreach (var param in parameters)
{
if (!uriParameters.ContainsKey(param.Key) && !bodyParameters.ContainsKey(param.Key))
{
throw new Exception($"Missing parameter {param.Key} after authentication processing. AuthenticationProvider implementation " +
$"should return provided parameters in either the uri or body parameters output");
}
}
// Add the auth parameters to the uri, start with a new URI to be able to sort the parameters including the auth parameters
uri = uri.SetParameters(uriParameters, arraySerialization);
var request = RequestFactory.Create(method, uri, requestId);
request.Accept = Constants.JsonContentHeader;
foreach (var header in headers)
request.AddHeader(header.Key, header.Value);
if (additionalHeaders != null)
{
foreach (var header in additionalHeaders)
request.AddHeader(header.Key, header.Value);
}
if (StandardRequestHeaders != null)
{
foreach (var header in StandardRequestHeaders)
{
// Only add it if it isn't overwritten
if (additionalHeaders?.ContainsKey(header.Key) != true)
request.AddHeader(header.Key, header.Value);
}
}
if (parameterPosition == HttpMethodParameterPosition.InBody)
{
var contentType = requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
if (bodyParameters.Any())
WriteParamBody(request, bodyParameters, contentType);
else
request.SetContent(requestBodyEmptyContent, contentType);
}
return request;
}
/// <summary>
/// Writes the parameters of the request to the request object body
/// </summary>
/// <param name="request">The request to set the parameters on</param>
/// <param name="parameters">The parameters to set</param>
/// <param name="contentType">The content type of the data</param>
protected virtual void WriteParamBody(IRequest request, SortedDictionary<string, object> parameters, string contentType)
{
if (requestBodyFormat == RequestBodyFormat.Json)
{
// Write the parameters as json in the body
var stringData = JsonConvert.SerializeObject(parameters);
request.SetContent(stringData, contentType);
}
else if (requestBodyFormat == RequestBodyFormat.FormData)
{
// Write the parameters as form data in the body
var stringData = parameters.ToFormData();
request.SetContent(stringData, contentType);
}
}
/// <summary>
/// Parse an error response from the server. Only used when server returns a status other than Success(200)
/// </summary>
/// <param name="error">The string the request returned</param>
/// <returns></returns>
protected virtual Error ParseErrorResponse(JToken error)
{
return new ServerError(error.ToString());
}
/// <summary>
/// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues
/// </summary>
/// <returns>Server time</returns>
protected abstract Task<WebCallResult<DateTime>> GetServerTimestampAsync();
protected virtual Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
internal async Task<WebCallResult<bool>> SyncTimeAsync()
{
var timeSyncParams = GetTimeSyncInfo();
if (timeSyncParams == null)
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, true, null);
if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false))
{
if (!timeSyncParams.SyncTime || (DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval))
@@ -92,9 +573,9 @@ namespace CryptoExchange.Net
}
// Calculate time offset between local and server
var offset = result.Data - localTime;
var offset = result.Data - (localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2));
timeSyncParams.UpdateTimeOffset(offset);
timeSyncParams.TimeSyncState.Semaphore.Release();
timeSyncParams.TimeSyncState.Semaphore.Release();
}
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, true, null);
+792 -3
View File
@@ -1,19 +1,808 @@
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net
{
/// <summary>
/// Base socket API client for interaction with a websocket API
/// </summary>
public abstract class SocketApiClient : BaseApiClient
public abstract class SocketApiClient : BaseApiClient, ISocketApiClient
{
#region Fields
/// <inheritdoc/>
public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory();
/// <summary>
/// List of socket connections currently connecting/connected
/// </summary>
protected internal ConcurrentDictionary<int, SocketConnection> socketConnections = new();
/// <summary>
/// Semaphore used while creating sockets
/// </summary>
protected internal readonly SemaphoreSlim semaphoreSlim = new(1);
/// <summary>
/// Keep alive interval for websocket connection
/// </summary>
protected TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Delegate used for processing byte data received from socket connections before it is processed by handlers
/// </summary>
protected Func<byte[], string>? dataInterpreterBytes;
/// <summary>
/// Delegate used for processing string data received from socket connections before it is processed by handlers
/// </summary>
protected Func<string, string>? dataInterpreterString;
/// <summary>
/// Handlers for data from the socket which doesn't need to be forwarded to the caller. Ping or welcome messages for example.
/// </summary>
protected Dictionary<string, Action<MessageEvent>> genericHandlers = new();
/// <summary>
/// The task that is sending periodic data on the websocket. Can be used for sending Ping messages every x seconds or similair. Not necesarry.
/// </summary>
protected Task? periodicTask;
/// <summary>
/// Wait event for the periodicTask
/// </summary>
protected AsyncResetEvent? periodicEvent;
/// <summary>
/// If true; data which is a response to a query will also be distributed to subscriptions
/// If false; data which is a response to a query won't get forwarded to subscriptions as well
/// </summary>
protected internal bool ContinueOnQueryResponse { get; protected set; }
/// <summary>
/// If a message is received on the socket which is not handled by a handler this boolean determines whether this logs an error message
/// </summary>
protected internal bool UnhandledMessageExpected { get; set; }
/// <summary>
/// The max amount of outgoing messages per socket per second
/// </summary>
protected internal int? RateLimitPerSocketPerSecond { get; set; }
/// <inheritdoc />
public double IncomingKbps
{
get
{
if (!socketConnections.Any())
return 0;
return socketConnections.Sum(s => s.Value.IncomingKbps);
}
}
/// <inheritdoc />
public int CurrentConnections => socketConnections.Count;
/// <inheritdoc />
public int CurrentSubscriptions
{
get
{
if (!socketConnections.Any())
return 0;
return socketConnections.Sum(s => s.Value.SubscriptionCount);
}
}
/// <inheritdoc />
public new SocketApiClientOptions Options => (SocketApiClientOptions)base.Options;
/// <summary>
/// Options
/// </summary>
internal ClientOptions ClientOptions { get; set; }
#endregion
/// <summary>
/// ctor
/// </summary>
/// <param name="options">The base client options</param>
/// <param name="log">log</param>
/// <param name="options">Client options</param>
/// <param name="apiOptions">The Api client options</param>
public SocketApiClient(BaseClientOptions options, ApiClientOptions apiOptions): base(options, apiOptions)
public SocketApiClient(Log log, ClientOptions options, SocketApiClientOptions apiOptions) : base(log, options, apiOptions)
{
ClientOptions = options;
}
/// <summary>
/// Set a delegate to be used for processing data received from socket connections before it is processed by handlers
/// </summary>
/// <param name="byteHandler">Handler for byte data</param>
/// <param name="stringHandler">Handler for string data</param>
protected void SetDataInterpreter(Func<byte[], string>? byteHandler, Func<string, string>? stringHandler)
{
dataInterpreterBytes = byteHandler;
dataInterpreterString = stringHandler;
}
/// <summary>
/// Connect to an url and listen for data on the BaseAddress
/// </summary>
/// <typeparam name="T">The type of the expected data</typeparam>
/// <param name="request">The optional request object to send, will be serialized to json</param>
/// <param name="identifier">The identifier to use, necessary if no request object is sent</param>
/// <param name="authenticated">If the subscription is to an authenticated endpoint</param>
/// <param name="dataHandler">The handler of update data</param>
/// <param name="ct">Cancellation token for closing this subscription</param>
/// <returns></returns>
protected virtual Task<CallResult<UpdateSubscription>> SubscribeAsync<T>(object? request, string? identifier, bool authenticated, Action<DataEvent<T>> dataHandler, CancellationToken ct)
{
return SubscribeAsync(Options.BaseAddress, request, identifier, authenticated, dataHandler, ct);
}
/// <summary>
/// Connect to an url and listen for data
/// </summary>
/// <typeparam name="T">The type of the expected data</typeparam>
/// <param name="url">The URL to connect to</param>
/// <param name="request">The optional request object to send, will be serialized to json</param>
/// <param name="identifier">The identifier to use, necessary if no request object is sent</param>
/// <param name="authenticated">If the subscription is to an authenticated endpoint</param>
/// <param name="dataHandler">The handler of update data</param>
/// <param name="ct">Cancellation token for closing this subscription</param>
/// <returns></returns>
protected virtual async Task<CallResult<UpdateSubscription>> SubscribeAsync<T>(string url, object? request, string? identifier, bool authenticated, Action<DataEvent<T>> dataHandler, CancellationToken ct)
{
if (_disposing)
return new CallResult<UpdateSubscription>(new InvalidOperationError("Client disposed, can't subscribe"));
SocketConnection socketConnection;
SocketSubscription? subscription;
var released = false;
// Wait for a semaphore here, so we only connect 1 socket at a time.
// This is necessary for being able to see if connections can be combined
try
{
await semaphoreSlim.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return new CallResult<UpdateSubscription>(new CancellationRequestedError());
}
try
{
while (true)
{
// Get a new or existing socket connection
var socketResult = await GetSocketConnection(url, authenticated).ConfigureAwait(false);
if (!socketResult)
return socketResult.As<UpdateSubscription>(null);
socketConnection = socketResult.Data;
// Add a subscription on the socket connection
subscription = AddSubscription(request, identifier, true, socketConnection, dataHandler, authenticated);
if (subscription == null)
{
_log.Write(LogLevel.Trace, $"Socket {socketConnection.SocketId} failed to add subscription, retrying on different connection");
continue;
}
if (Options.SocketSubscriptionsCombineTarget == 1)
{
// Only 1 subscription per connection, so no need to wait for connection since a new subscription will create a new connection anyway
semaphoreSlim.Release();
released = true;
}
var needsConnecting = !socketConnection.Connected;
var connectResult = await ConnectIfNeededAsync(socketConnection, authenticated).ConfigureAwait(false);
if (!connectResult)
return new CallResult<UpdateSubscription>(connectResult.Error!);
break;
}
}
finally
{
if (!released)
semaphoreSlim.Release();
}
if (socketConnection.PausedActivity)
{
_log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} has been paused, can't subscribe at this moment");
return new CallResult<UpdateSubscription>(new ServerError("Socket is paused"));
}
if (request != null)
{
// Send the request and wait for answer
var subResult = await SubscribeAndWaitAsync(socketConnection, request, subscription).ConfigureAwait(false);
if (!subResult)
{
_log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} failed to subscribe: {subResult.Error}");
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
return new CallResult<UpdateSubscription>(subResult.Error!);
}
}
else
{
// No request to be sent, so just mark the subscription as comfirmed
subscription.Confirmed = true;
}
if (ct != default)
{
subscription.CancellationTokenRegistration = ct.Register(async () =>
{
_log.Write(LogLevel.Information, $"Socket {socketConnection.SocketId} Cancellation token set, closing subscription");
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
}, false);
}
_log.Write(LogLevel.Information, $"Socket {socketConnection.SocketId} subscription {subscription.Id} completed successfully");
return new CallResult<UpdateSubscription>(new UpdateSubscription(socketConnection, subscription));
}
/// <summary>
/// Sends the subscribe request and waits for a response to that request
/// </summary>
/// <param name="socketConnection">The connection to send the request on</param>
/// <param name="request">The request to send, will be serialized to json</param>
/// <param name="subscription">The subscription the request is for</param>
/// <returns></returns>
protected internal virtual async Task<CallResult<bool>> SubscribeAndWaitAsync(SocketConnection socketConnection, object request, SocketSubscription subscription)
{
CallResult<object>? callResult = null;
await socketConnection.SendAndWaitAsync(request, Options.SocketResponseTimeout, subscription, data => HandleSubscriptionResponse(socketConnection, subscription, request, data, out callResult)).ConfigureAwait(false);
if (callResult?.Success == true)
{
subscription.Confirmed = true;
return new CallResult<bool>(true);
}
if (callResult == null)
return new CallResult<bool>(new ServerError("No response on subscription request received"));
return new CallResult<bool>(callResult.Error!);
}
/// <summary>
/// Send a query on a socket connection to the BaseAddress and wait for the response
/// </summary>
/// <typeparam name="T">Expected result type</typeparam>
/// <param name="request">The request to send, will be serialized to json</param>
/// <param name="authenticated">If the query is to an authenticated endpoint</param>
/// <returns></returns>
protected virtual Task<CallResult<T>> QueryAsync<T>(object request, bool authenticated)
{
return QueryAsync<T>(Options.BaseAddress, request, authenticated);
}
/// <summary>
/// Send a query on a socket connection and wait for the response
/// </summary>
/// <typeparam name="T">The expected result type</typeparam>
/// <param name="url">The url for the request</param>
/// <param name="request">The request to send</param>
/// <param name="authenticated">Whether the socket should be authenticated</param>
/// <returns></returns>
protected virtual async Task<CallResult<T>> QueryAsync<T>(string url, object request, bool authenticated)
{
if (_disposing)
return new CallResult<T>(new InvalidOperationError("Client disposed, can't query"));
SocketConnection socketConnection;
var released = false;
await semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
var socketResult = await GetSocketConnection(url, authenticated).ConfigureAwait(false);
if (!socketResult)
return socketResult.As<T>(default);
socketConnection = socketResult.Data;
if (Options.SocketSubscriptionsCombineTarget == 1)
{
// Can release early when only a single sub per connection
semaphoreSlim.Release();
released = true;
}
var connectResult = await ConnectIfNeededAsync(socketConnection, authenticated).ConfigureAwait(false);
if (!connectResult)
return new CallResult<T>(connectResult.Error!);
}
finally
{
if (!released)
semaphoreSlim.Release();
}
if (socketConnection.PausedActivity)
{
_log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} has been paused, can't send query at this moment");
return new CallResult<T>(new ServerError("Socket is paused"));
}
return await QueryAndWaitAsync<T>(socketConnection, request).ConfigureAwait(false);
}
/// <summary>
/// Sends the query request and waits for the result
/// </summary>
/// <typeparam name="T">The expected result type</typeparam>
/// <param name="socket">The connection to send and wait on</param>
/// <param name="request">The request to send</param>
/// <returns></returns>
protected virtual async Task<CallResult<T>> QueryAndWaitAsync<T>(SocketConnection socket, object request)
{
var dataResult = new CallResult<T>(new ServerError("No response on query received"));
await socket.SendAndWaitAsync(request, Options.SocketResponseTimeout, null, data =>
{
if (!HandleQueryResponse<T>(socket, request, data, out var callResult))
return false;
dataResult = callResult;
return true;
}).ConfigureAwait(false);
return dataResult;
}
/// <summary>
/// Checks if a socket needs to be connected and does so if needed. Also authenticates on the socket if needed
/// </summary>
/// <param name="socket">The connection to check</param>
/// <param name="authenticated">Whether the socket should authenticated</param>
/// <returns></returns>
protected virtual async Task<CallResult<bool>> ConnectIfNeededAsync(SocketConnection socket, bool authenticated)
{
if (socket.Connected)
return new CallResult<bool>(true);
var connectResult = await ConnectSocketAsync(socket).ConfigureAwait(false);
if (!connectResult)
return new CallResult<bool>(connectResult.Error!);
if (Options.DelayAfterConnect != TimeSpan.Zero)
await Task.Delay(Options.DelayAfterConnect).ConfigureAwait(false);
if (!authenticated || socket.Authenticated)
return new CallResult<bool>(true);
_log.Write(LogLevel.Debug, $"Attempting to authenticate {socket.SocketId}");
var result = await AuthenticateSocketAsync(socket).ConfigureAwait(false);
if (!result)
{
_log.Write(LogLevel.Warning, $"Socket {socket.SocketId} authentication failed");
if (socket.Connected)
await socket.CloseAsync().ConfigureAwait(false);
result.Error!.Message = "Authentication failed: " + result.Error.Message;
return new CallResult<bool>(result.Error);
}
socket.Authenticated = true;
return new CallResult<bool>(true);
}
/// <summary>
/// The socketConnection received data (the data JToken parameter). The implementation of this method should check if the received data is a response to the query that was send (the request parameter).
/// For example; A query is sent in a request message with an Id parameter with value 10. The socket receives data and calls this method to see if the data it received is an
/// anwser to any query that was done. The implementation of this method should check if the response.Id == request.Id to see if they match (assuming the api has some sort of Id tracking on messages,
/// if not some other method has be implemented to match the messages).
/// If the messages match, the callResult out parameter should be set with the deserialized data in the from of (T) and return true.
/// </summary>
/// <typeparam name="T">The type of response that is expected on the query</typeparam>
/// <param name="socketConnection">The socket connection</param>
/// <param name="request">The request that a response is awaited for</param>
/// <param name="data">The message received from the server</param>
/// <param name="callResult">The interpretation (null if message wasn't a response to the request)</param>
/// <returns>True if the message was a response to the query</returns>
protected internal abstract bool HandleQueryResponse<T>(SocketConnection socketConnection, object request, JToken data, [NotNullWhen(true)] out CallResult<T>? callResult);
/// <summary>
/// The socketConnection received data (the data JToken parameter). The implementation of this method should check if the received data is a response to the subscription request that was send (the request parameter).
/// For example; A subscribe request message is send with an Id parameter with value 10. The socket receives data and calls this method to see if the data it received is an
/// anwser to any subscription request that was done. The implementation of this method should check if the response.Id == request.Id to see if they match (assuming the api has some sort of Id tracking on messages,
/// if not some other method has be implemented to match the messages).
/// If the messages match, the callResult out parameter should be set with the deserialized data in the from of (T) and return true.
/// </summary>
/// <param name="socketConnection">The socket connection</param>
/// <param name="subscription">A subscription that waiting for a subscription response</param>
/// <param name="request">The request that the subscription sent</param>
/// <param name="data">The message received from the server</param>
/// <param name="callResult">The interpretation (null if message wasn't a response to the request)</param>
/// <returns>True if the message was a response to the subscription request</returns>
protected internal abstract bool HandleSubscriptionResponse(SocketConnection socketConnection, SocketSubscription subscription, object request, JToken data, out CallResult<object>? callResult);
/// <summary>
/// Needs to check if a received message matches a handler by request. After subscribing data message will come in. These data messages need to be matched to a specific connection
/// to pass the correct data to the correct handler. The implementation of this method should check if the message received matches the subscribe request that was sent.
/// </summary>
/// <param name="socketConnection">The socket connection the message was recieved on</param>
/// <param name="message">The received data</param>
/// <param name="request">The subscription request</param>
/// <returns>True if the message is for the subscription which sent the request</returns>
protected internal abstract bool MessageMatchesHandler(SocketConnection socketConnection, JToken message, object request);
/// <summary>
/// Needs to check if a received message matches a handler by identifier. Generally used by GenericHandlers. For example; a generic handler is registered which handles ping messages
/// from the server. This method should check if the message received is a ping message and the identifer is the identifier of the GenericHandler
/// </summary>
/// <param name="socketConnection">The socket connection the message was recieved on</param>
/// <param name="message">The received data</param>
/// <param name="identifier">The string identifier of the handler</param>
/// <returns>True if the message is for the handler which has the identifier</returns>
protected internal abstract bool MessageMatchesHandler(SocketConnection socketConnection, JToken message, string identifier);
/// <summary>
/// Needs to authenticate the socket so authenticated queries/subscriptions can be made on this socket connection
/// </summary>
/// <param name="socketConnection">The socket connection that should be authenticated</param>
/// <returns></returns>
protected internal abstract Task<CallResult<bool>> AuthenticateSocketAsync(SocketConnection socketConnection);
/// <summary>
/// Needs to unsubscribe a subscription, typically by sending an unsubscribe request. If multiple subscriptions per socket is not allowed this can just return since the socket will be closed anyway
/// </summary>
/// <param name="connection">The connection on which to unsubscribe</param>
/// <param name="subscriptionToUnsub">The subscription to unsubscribe</param>
/// <returns></returns>
protected internal abstract Task<bool> UnsubscribeAsync(SocketConnection connection, SocketSubscription subscriptionToUnsub);
/// <summary>
/// Optional handler to interpolate data before sending it to the handlers
/// </summary>
/// <param name="message"></param>
/// <returns></returns>
protected internal virtual JToken ProcessTokenData(JToken message)
{
return message;
}
/// <summary>
/// Add a subscription to a connection
/// </summary>
/// <typeparam name="T">The type of data the subscription expects</typeparam>
/// <param name="request">The request of the subscription</param>
/// <param name="identifier">The identifier of the subscription (can be null if request param is used)</param>
/// <param name="userSubscription">Whether or not this is a user subscription (counts towards the max amount of handlers on a socket)</param>
/// <param name="connection">The socket connection the handler is on</param>
/// <param name="dataHandler">The handler of the data received</param>
/// <param name="authenticated">Whether the subscription needs authentication</param>
/// <returns></returns>
protected virtual SocketSubscription? AddSubscription<T>(object? request, string? identifier, bool userSubscription, SocketConnection connection, Action<DataEvent<T>> dataHandler, bool authenticated)
{
void InternalHandler(MessageEvent messageEvent)
{
if (typeof(T) == typeof(string))
{
var stringData = (T)Convert.ChangeType(messageEvent.JsonData.ToString(), typeof(T));
dataHandler(new DataEvent<T>(stringData, null, Options.OutputOriginalData ? messageEvent.OriginalData : null, messageEvent.ReceivedTimestamp));
return;
}
var desResult = Deserialize<T>(messageEvent.JsonData);
if (!desResult)
{
_log.Write(LogLevel.Warning, $"Socket {connection.SocketId} Failed to deserialize data into type {typeof(T)}: {desResult.Error}");
return;
}
dataHandler(new DataEvent<T>(desResult.Data, null, Options.OutputOriginalData ? messageEvent.OriginalData : null, messageEvent.ReceivedTimestamp));
}
var subscription = request == null
? SocketSubscription.CreateForIdentifier(NextId(), identifier!, userSubscription, authenticated, InternalHandler)
: SocketSubscription.CreateForRequest(NextId(), request, userSubscription, authenticated, InternalHandler);
if (!connection.AddSubscription(subscription))
return null;
return subscription;
}
/// <summary>
/// Adds a generic message handler. Used for example to reply to ping requests
/// </summary>
/// <param name="identifier">The name of the request handler. Needs to be unique</param>
/// <param name="action">The action to execute when receiving a message for this handler (checked by <see cref="MessageMatchesHandler(SocketConnection, Newtonsoft.Json.Linq.JToken,string)"/>)</param>
protected void AddGenericHandler(string identifier, Action<MessageEvent> action)
{
genericHandlers.Add(identifier, action);
var subscription = SocketSubscription.CreateForIdentifier(NextId(), identifier, false, false, action);
foreach (var connection in socketConnections.Values)
connection.AddSubscription(subscription);
}
/// <summary>
/// Get the url to connect to (defaults to BaseAddress form the client options)
/// </summary>
/// <param name="address"></param>
/// <param name="authentication"></param>
/// <returns></returns>
protected virtual Task<CallResult<string?>> GetConnectionUrlAsync(string address, bool authentication)
{
return Task.FromResult(new CallResult<string?>(address));
}
/// <summary>
/// Get the url to reconnect to after losing a connection
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
public virtual Task<Uri?> GetReconnectUriAsync(SocketConnection connection)
{
return Task.FromResult<Uri?>(connection.ConnectionUri);
}
/// <summary>
/// Update the original request to send when the connection is restored after disconnecting. Can be used to update an authentication token for example.
/// </summary>
/// <param name="request">The original request</param>
/// <returns></returns>
public virtual Task<CallResult<object>> RevitalizeRequestAsync(object request)
{
return Task.FromResult(new CallResult<object>(request));
}
/// <summary>
/// Gets a connection for a new subscription or query. Can be an existing if there are open position or a new one.
/// </summary>
/// <param name="address">The address the socket is for</param>
/// <param name="authenticated">Whether the socket should be authenticated</param>
/// <returns></returns>
protected virtual async Task<CallResult<SocketConnection>> GetSocketConnection(string address, bool authenticated)
{
var socketResult = socketConnections.Where(s => (s.Value.Status == SocketConnection.SocketStatus.None || s.Value.Status == SocketConnection.SocketStatus.Connected)
&& s.Value.Tag.TrimEnd('/') == address.TrimEnd('/')
&& (s.Value.ApiClient.GetType() == GetType())
&& (s.Value.Authenticated == authenticated || !authenticated) && s.Value.Connected).OrderBy(s => s.Value.SubscriptionCount).FirstOrDefault();
var result = socketResult.Equals(default(KeyValuePair<int, SocketConnection>)) ? null : socketResult.Value;
if (result != null)
{
if (result.SubscriptionCount < Options.SocketSubscriptionsCombineTarget || (socketConnections.Count >= Options.MaxSocketConnections && socketConnections.All(s => s.Value.SubscriptionCount >= Options.SocketSubscriptionsCombineTarget)))
{
// Use existing socket if it has less than target connections OR it has the least connections and we can't make new
return new CallResult<SocketConnection>(result);
}
}
var connectionAddress = await GetConnectionUrlAsync(address, authenticated).ConfigureAwait(false);
if (!connectionAddress)
{
_log.Write(LogLevel.Warning, $"Failed to determine connection url: " + connectionAddress.Error);
return connectionAddress.As<SocketConnection>(null);
}
if (connectionAddress.Data != address)
_log.Write(LogLevel.Debug, $"Connection address set to " + connectionAddress.Data);
// Create new socket
var socket = CreateSocket(connectionAddress.Data!);
var socketConnection = new SocketConnection(_log, this, socket, address);
socketConnection.UnhandledMessage += HandleUnhandledMessage;
foreach (var kvp in genericHandlers)
{
var handler = SocketSubscription.CreateForIdentifier(NextId(), kvp.Key, false, false, kvp.Value);
socketConnection.AddSubscription(handler);
}
return new CallResult<SocketConnection>(socketConnection);
}
/// <summary>
/// Process an unhandled message
/// </summary>
/// <param name="token">The token that wasn't processed</param>
protected virtual void HandleUnhandledMessage(JToken token)
{
}
/// <summary>
/// Connect a socket
/// </summary>
/// <param name="socketConnection">The socket to connect</param>
/// <returns></returns>
protected virtual async Task<CallResult<bool>> ConnectSocketAsync(SocketConnection socketConnection)
{
if (await socketConnection.ConnectAsync().ConfigureAwait(false))
{
socketConnections.TryAdd(socketConnection.SocketId, socketConnection);
return new CallResult<bool>(true);
}
socketConnection.Dispose();
return new CallResult<bool>(new CantConnectError());
}
/// <summary>
/// Get parameters for the websocket connection
/// </summary>
/// <param name="address">The address to connect to</param>
/// <returns></returns>
protected virtual WebSocketParameters GetWebSocketParameters(string address)
=> new(new Uri(address), Options.AutoReconnect)
{
DataInterpreterBytes = dataInterpreterBytes,
DataInterpreterString = dataInterpreterString,
KeepAliveInterval = KeepAliveInterval,
ReconnectInterval = Options.ReconnectInterval,
RatelimitPerSecond = RateLimitPerSocketPerSecond,
Proxy = ClientOptions.Proxy,
Timeout = Options.SocketNoDataTimeout
};
/// <summary>
/// Create a socket for an address
/// </summary>
/// <param name="address">The address the socket should connect to</param>
/// <returns></returns>
protected virtual IWebsocket CreateSocket(string address)
{
var socket = SocketFactory.CreateWebsocket(_log, GetWebSocketParameters(address));
_log.Write(LogLevel.Debug, $"Socket {socket.Id} new socket created for " + address);
return socket;
}
/// <summary>
/// Periodically sends data over a socket connection
/// </summary>
/// <param name="identifier">Identifier for the periodic send</param>
/// <param name="interval">How often</param>
/// <param name="objGetter">Method returning the object to send</param>
public virtual void SendPeriodic(string identifier, TimeSpan interval, Func<SocketConnection, object> objGetter)
{
if (objGetter == null)
throw new ArgumentNullException(nameof(objGetter));
periodicEvent = new AsyncResetEvent();
periodicTask = Task.Run(async () =>
{
while (!_disposing)
{
await periodicEvent.WaitAsync(interval).ConfigureAwait(false);
if (_disposing)
break;
foreach (var socketConnection in socketConnections.Values)
{
if (_disposing)
break;
if (!socketConnection.Connected)
continue;
var obj = objGetter(socketConnection);
if (obj == null)
continue;
_log.Write(LogLevel.Trace, $"Socket {socketConnection.SocketId} sending periodic {identifier}");
try
{
socketConnection.Send(obj);
}
catch (Exception ex)
{
_log.Write(LogLevel.Warning, $"Socket {socketConnection.SocketId} Periodic send {identifier} failed: " + ex.ToLogString());
}
}
}
});
}
/// <summary>
/// Unsubscribe an update subscription
/// </summary>
/// <param name="subscriptionId">The id of the subscription to unsubscribe</param>
/// <returns></returns>
public virtual async Task<bool> UnsubscribeAsync(int subscriptionId)
{
SocketSubscription? subscription = null;
SocketConnection? connection = null;
foreach (var socket in socketConnections.Values.ToList())
{
subscription = socket.GetSubscription(subscriptionId);
if (subscription != null)
{
connection = socket;
break;
}
}
if (subscription == null || connection == null)
return false;
_log.Write(LogLevel.Information, $"Socket {connection.SocketId} Unsubscribing subscription " + subscriptionId);
await connection.CloseAsync(subscription).ConfigureAwait(false);
return true;
}
/// <summary>
/// Unsubscribe an update subscription
/// </summary>
/// <param name="subscription">The subscription to unsubscribe</param>
/// <returns></returns>
public virtual async Task UnsubscribeAsync(UpdateSubscription subscription)
{
if (subscription == null)
throw new ArgumentNullException(nameof(subscription));
_log.Write(LogLevel.Information, $"Socket {subscription.SocketId} Unsubscribing subscription " + subscription.Id);
await subscription.CloseAsync().ConfigureAwait(false);
}
/// <summary>
/// Unsubscribe all subscriptions
/// </summary>
/// <returns></returns>
public virtual async Task UnsubscribeAllAsync()
{
_log.Write(LogLevel.Information, $"Unsubscribing all {socketConnections.Sum(s => s.Value.SubscriptionCount)} subscriptions");
var tasks = new List<Task>();
{
var socketList = socketConnections.Values;
foreach (var sub in socketList)
tasks.Add(sub.CloseAsync());
}
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
/// <summary>
/// Reconnect all connections
/// </summary>
/// <returns></returns>
public virtual async Task ReconnectAsync()
{
_log.Write(LogLevel.Information, $"Reconnecting all {socketConnections.Count} connections");
var tasks = new List<Task>();
{
var socketList = socketConnections.Values;
foreach (var sub in socketList)
tasks.Add(sub.TriggerReconnectAsync());
}
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
/// <summary>
/// Log the current state of connections and subscriptions
/// </summary>
public string GetSubscriptionsState()
{
var sb = new StringBuilder();
sb.AppendLine($"{socketConnections.Count} connections, {CurrentSubscriptions} subscriptions, kbps: {IncomingKbps}");
foreach (var connection in socketConnections)
{
sb.AppendLine($" Connection {connection.Key}: {connection.Value.SubscriptionCount} subscriptions, status: {connection.Value.Status}, authenticated: {connection.Value.Authenticated}, kbps: {connection.Value.IncomingKbps}");
foreach (var subscription in connection.Value.Subscriptions)
sb.AppendLine($" Subscription {subscription.Id}, authenticated: {subscription.Authenticated}, confirmed: {subscription.Confirmed}");
}
return sb.ToString();
}
/// <summary>
/// Dispose the client
/// </summary>
public override void Dispose()
{
_disposing = true;
periodicEvent?.Set();
periodicEvent?.Dispose();
if (socketConnections.Sum(s => s.Value.SubscriptionCount) > 0)
{
_log.Write(LogLevel.Debug, "Disposing socket client, closing all subscriptions");
_ = UnsubscribeAllAsync();
}
semaphoreSlim?.Dispose();
base.Dispose();
}
}
}
@@ -28,6 +28,9 @@ namespace CryptoExchange.Net.Converters
/// <inheritdoc />
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
if (objectType == typeof(JToken))
return JToken.Load(reader);
@@ -106,7 +109,7 @@ namespace CryptoExchange.Net.Converters
if ((property.PropertyType == typeof(decimal)
|| property.PropertyType == typeof(decimal?))
&& (value != null && value.ToString().Contains("e")))
&& (value != null && value.ToString().IndexOf("e", StringComparison.OrdinalIgnoreCase) >= 0))
{
if (decimal.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var dec))
property.SetValue(result, dec);
@@ -1,5 +1,4 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -32,13 +31,13 @@ namespace CryptoExchange.Net.Converters
if(reader.TokenType is JsonToken.Integer)
{
var longValue = (long)reader.Value;
if (longValue == 0)
if (longValue == 0 || longValue == -1)
return objectType == typeof(DateTime) ? default(DateTime): null;
if (longValue < 1999999999)
if (longValue < 19999999999)
return ConvertFromSeconds(longValue);
if (longValue < 1999999999999)
if (longValue < 19999999999999)
return ConvertFromMilliseconds(longValue);
if (longValue < 1999999999999999)
if (longValue < 19999999999999999)
return ConvertFromMicroseconds(longValue);
return ConvertFromNanoseconds(longValue);
@@ -46,7 +45,10 @@ namespace CryptoExchange.Net.Converters
else if (reader.TokenType is JsonToken.Float)
{
var doubleValue = (double)reader.Value;
if (doubleValue < 1999999999)
if (doubleValue == 0 || doubleValue == -1)
return objectType == typeof(DateTime) ? default(DateTime) : null;
if (doubleValue < 19999999999)
return ConvertFromSeconds(doubleValue);
return ConvertFromMilliseconds(doubleValue);
@@ -57,6 +59,9 @@ namespace CryptoExchange.Net.Converters
if (string.IsNullOrWhiteSpace(stringValue))
return null;
if (string.IsNullOrWhiteSpace(stringValue) || stringValue == "0" || stringValue == "-1")
return objectType == typeof(DateTime) ? default(DateTime) : null;
if (stringValue.Length == 8)
{
// Parse 20211103 format
@@ -86,11 +91,11 @@ namespace CryptoExchange.Net.Converters
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
// Parse 1637745563.000 format
if (doubleValue < 1999999999)
if (doubleValue < 19999999999)
return ConvertFromSeconds(doubleValue);
if (doubleValue < 1999999999999)
if (doubleValue < 19999999999999)
return ConvertFromMilliseconds((long)doubleValue);
if (doubleValue < 1999999999999999)
if (doubleValue < 19999999999999999)
return ConvertFromMicroseconds((long)doubleValue);
return ConvertFromNanoseconds((long)doubleValue);
@@ -132,7 +132,7 @@ namespace CryptoExchange.Net.Converters
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
var stringValue = GetString(value);
writer.WriteRawValue(stringValue);
writer.WriteValue(stringValue);
}
}
}
+4 -4
View File
@@ -6,16 +6,16 @@
<PackageId>CryptoExchange.Net</PackageId>
<Authors>JKorf</Authors>
<Description>A base package for implementing cryptocurrency API's</Description>
<PackageVersion>5.1.6</PackageVersion>
<AssemblyVersion>5.1.6</AssemblyVersion>
<FileVersion>5.1.6</FileVersion>
<PackageVersion>5.4.0</PackageVersion>
<AssemblyVersion>5.4.0</AssemblyVersion>
<FileVersion>5.4.0</FileVersion>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/JKorf/CryptoExchange.Net.git</RepositoryUrl>
<PackageProjectUrl>https://github.com/JKorf/CryptoExchange.Net</PackageProjectUrl>
<NeutralLanguage>en</NeutralLanguage>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageReleaseNotes>5.1.6 - Updated EnumConverter to properly handle emtpy/null and default values</PackageReleaseNotes>
<PackageReleaseNotes>5.4.0 - Added unsubscribing when receiving subscribe answer after the request timeout has passed, Fixed socket options copying, Made TimeSync implementation optional, Cleaned up ApiCredentials and added better support for extending ApiCredentials</PackageReleaseNotes>
<Nullable>enable</Nullable>
<LangVersion>9.0</LangVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
+2 -2
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@@ -427,6 +426,7 @@ namespace CryptoExchange.Net
var uriBuilder = new UriBuilder();
uriBuilder.Scheme = baseUri.Scheme;
uriBuilder.Host = baseUri.Host;
uriBuilder.Port = baseUri.Port;
uriBuilder.Path = baseUri.AbsolutePath;
var httpValueCollection = HttpUtility.ParseQueryString(string.Empty);
foreach (var parameter in parameters)
@@ -455,6 +455,7 @@ namespace CryptoExchange.Net
var uriBuilder = new UriBuilder();
uriBuilder.Scheme = baseUri.Scheme;
uriBuilder.Host = baseUri.Host;
uriBuilder.Port = baseUri.Port;
uriBuilder.Path = baseUri.AbsolutePath;
var httpValueCollection = HttpUtility.ParseQueryString(string.Empty);
foreach (var parameter in parameters)
@@ -491,7 +492,6 @@ namespace CryptoExchange.Net
return ub.Uri;
}
}
}
@@ -0,0 +1,17 @@
using CryptoExchange.Net.Authentication;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Base api client
/// </summary>
public interface IBaseApiClient
{
/// <summary>
/// Set the API credentials for this API client
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="credentials"></param>
void SetApiCredentials<T>(T credentials) where T : ApiCredentials;
}
}
@@ -0,0 +1,33 @@
using CryptoExchange.Net.Objects;
using System;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Base rest API client
/// </summary>
public interface IRestApiClient : IBaseApiClient
{
/// <summary>
/// The factory for creating requests. Used for unit testing
/// </summary>
IRequestFactory RequestFactory { get; set; }
/// <summary>
/// Total amount of requests made with this API client
/// </summary>
int TotalRequestsMade { get; set; }
/// <summary>
/// Get time offset for an API client. Return null if time syncing shouldnt/cant be done
/// </summary>
/// <returns></returns>
TimeSpan? GetTimeOffset();
/// <summary>
/// Get time sync info for an API client. Return null if time syncing shouldnt/cant be done
/// </summary>
/// <returns></returns>
TimeSyncInfo? GetTimeSyncInfo();
}
}
+2 -13
View File
@@ -10,24 +10,13 @@ namespace CryptoExchange.Net.Interfaces
public interface IRestClient: IDisposable
{
/// <summary>
/// The factory for creating requests. Used for unit testing
/// The options provided for this client
/// </summary>
IRequestFactory RequestFactory { get; set; }
ClientOptions ClientOptions { get; }
/// <summary>
/// The total amount of requests made with this client
/// </summary>
int TotalRequestsMade { get; }
/// <summary>
/// The options provided for this client
/// </summary>
BaseRestClientOptions ClientOptions { get; }
/// <summary>
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
/// </summary>
/// <param name="credentials">The credentials to set</param>
void SetApiCredentials(ApiCredentials credentials);
}
}
@@ -0,0 +1,81 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
using System;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Socket API client
/// </summary>
public interface ISocketApiClient: IBaseApiClient
{
/// <summary>
/// The current amount of socket connections on the API client
/// </summary>
int CurrentConnections { get; }
/// <summary>
/// The current amount of subscriptions over all connections
/// </summary>
int CurrentSubscriptions { get; }
/// <summary>
/// Incoming data kpbs
/// </summary>
double IncomingKbps { get; }
/// <summary>
/// Client options
/// </summary>
SocketApiClientOptions Options { get; }
/// <summary>
/// The factory for creating sockets. Used for unit testing
/// </summary>
IWebsocketFactory SocketFactory { get; set; }
/// <summary>
/// Get the url to reconnect to after losing a connection
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
Task<Uri?> GetReconnectUriAsync(SocketConnection connection);
/// <summary>
/// Log the current state of connections and subscriptions
/// </summary>
string GetSubscriptionsState();
/// <summary>
/// Reconnect all connections
/// </summary>
/// <returns></returns>
Task ReconnectAsync();
/// <summary>
/// Update the original request to send when the connection is restored after disconnecting. Can be used to update an authentication token for example.
/// </summary>
/// <param name="request">The original request</param>
/// <returns></returns>
Task<CallResult<object>> RevitalizeRequestAsync(object request);
/// <summary>
/// Periodically sends data over a socket connection
/// </summary>
/// <param name="identifier">Identifier for the periodic send</param>
/// <param name="interval">How often</param>
/// <param name="objGetter">Method returning the object to send</param>
void SendPeriodic(string identifier, TimeSpan interval, Func<SocketConnection, object> objGetter);
/// <summary>
/// Unsubscribe all subscriptions
/// </summary>
/// <returns></returns>
Task UnsubscribeAllAsync();
/// <summary>
/// Unsubscribe an update subscription
/// </summary>
/// <param name="subscriptionId">The id of the subscription to unsubscribe</param>
/// <returns></returns>
Task<bool> UnsubscribeAsync(int subscriptionId);
/// <summary>
/// Unsubscribe an update subscription
/// </summary>
/// <param name="subscription">The subscription to unsubscribe</param>
/// <returns></returns>
Task UnsubscribeAsync(UpdateSubscription subscription);
}
}
+12 -1
View File
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
@@ -13,13 +14,23 @@ namespace CryptoExchange.Net.Interfaces
/// <summary>
/// The options provided for this client
/// </summary>
BaseSocketClientOptions ClientOptions { get; }
ClientOptions ClientOptions { get; }
/// <summary>
/// Incoming kilobytes per second of data
/// </summary>
public double IncomingKbps { get; }
/// <summary>
/// The current amount of connections to the API from this client. A connection can have multiple subscriptions.
/// </summary>
public int CurrentConnections { get; }
/// <summary>
/// The current amount of subscriptions running from the client
/// </summary>
public int CurrentSubscriptions { get; }
/// <summary>
/// Unsubscribe from a stream using the subscription id received when starting the subscription
/// </summary>
+18 -41
View File
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
using System;
using System.Security.Authentication;
using System.Text;
@@ -27,43 +28,31 @@ namespace CryptoExchange.Net.Interfaces
/// Websocket opened event
/// </summary>
event Action OnOpen;
/// <summary>
/// Websocket has lost connection to the server and is attempting to reconnect
/// </summary>
event Action OnReconnecting;
/// <summary>
/// Websocket has reconnected to the server
/// </summary>
event Action OnReconnected;
/// <summary>
/// Get reconntion url
/// </summary>
Func<Task<Uri?>>? GetReconnectionUrl { get; set; }
/// <summary>
/// Unique id for this socket
/// </summary>
int Id { get; }
/// <summary>
/// Origin header
/// </summary>
string? Origin { get; set; }
/// <summary>
/// Encoding to use for sending/receiving string data
/// </summary>
Encoding? Encoding { get; set; }
/// <summary>
/// Whether socket is in the process of reconnecting
/// </summary>
bool Reconnecting { get; set; }
/// <summary>
/// The max amount of outgoing messages per second
/// </summary>
int? RatelimitPerSecond { get; set; }
/// <summary>
/// The current kilobytes per second of data being received, averaged over the last 3 seconds
/// </summary>
double IncomingKbps { get; }
/// <summary>
/// Handler for byte data
/// The uri the socket connects to
/// </summary>
Func<byte[], string>? DataInterpreterBytes { get; set; }
/// <summary>
/// Handler for string data
/// </summary>
Func<string, string>? DataInterpreterString { get; set; }
/// <summary>
/// The url the socket connects to
/// </summary>
string Url { get; }
Uri Uri { get; }
/// <summary>
/// Whether the socket connection is closed
/// </summary>
@@ -73,19 +62,6 @@ namespace CryptoExchange.Net.Interfaces
/// </summary>
bool IsOpen { get; }
/// <summary>
/// Supported ssl protocols
/// </summary>
SslProtocols SSLProtocols { get; set; }
/// <summary>
/// The max time for no data being received before the connection is considered lost
/// </summary>
TimeSpan Timeout { get; set; }
/// <summary>
/// Set a proxy to use when connecting
/// </summary>
/// <param name="proxy"></param>
void SetProxy(ApiProxy proxy);
/// <summary>
/// Connect the socket
/// </summary>
/// <returns></returns>
@@ -96,9 +72,10 @@ namespace CryptoExchange.Net.Interfaces
/// <param name="data"></param>
void Send(string data);
/// <summary>
/// Reset socket when a connection is lost to prepare for a new connection
/// Reconnect the socket
/// </summary>
void Reset();
/// <returns></returns>
Task ReconnectAsync();
/// <summary>
/// Close the connection
/// </summary>
@@ -1,5 +1,5 @@
using System.Collections.Generic;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Sockets;
namespace CryptoExchange.Net.Interfaces
{
@@ -12,17 +12,8 @@ namespace CryptoExchange.Net.Interfaces
/// Create a websocket for an url
/// </summary>
/// <param name="log">The logger</param>
/// <param name="url">The url the socket is fo</param>
/// <param name="parameters">The parameters to use for the connection</param>
/// <returns></returns>
IWebsocket CreateWebsocket(Log log, string url);
/// <summary>
/// Create a websocket for an url
/// </summary>
/// <param name="log">The logger</param>
/// <param name="url">The url the socket is fo</param>
/// <param name="cookies">Cookies to be send in the initial request</param>
/// <param name="headers">Headers to be send in the initial request</param>
/// <returns></returns>
IWebsocket CreateWebsocket(Log log, string url, IDictionary<string, string> cookies, IDictionary<string, string> headers);
IWebsocket CreateWebsocket(Log log, WebSocketParameters parameters);
}
}
+15 -9
View File
@@ -26,6 +26,8 @@ namespace CryptoExchange.Net.Logging
/// </summary>
public string ClientName { get; set; }
private readonly object _lock = new object();
/// <summary>
/// ctor
/// </summary>
@@ -42,7 +44,8 @@ namespace CryptoExchange.Net.Logging
/// <param name="textWriters"></param>
public void UpdateWriters(List<ILogger> textWriters)
{
writers = textWriters;
lock (_lock)
writers = textWriters;
}
/// <summary>
@@ -56,16 +59,19 @@ namespace CryptoExchange.Net.Logging
return;
var logMessage = $"{ClientName,-10} | {message}";
foreach (var writer in writers.ToList())
lock (_lock)
{
try
foreach (var writer in writers)
{
writer.Log(logLevel, logMessage);
}
catch (Exception e)
{
// Can't write to the logging so where else to output..
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Failed to write log to writer {writer.GetType()}: " + e.ToLogString());
try
{
writer.Log(logLevel, logMessage);
}
catch (Exception e)
{
// Can't write to the logging so where else to output..
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Failed to write log to writer {writer.GetType()}: " + e.ToLogString());
}
}
}
}
+157 -175
View File
@@ -10,215 +10,89 @@ using Microsoft.Extensions.Logging;
namespace CryptoExchange.Net.Objects
{
/// <summary>
/// Base options, applicable to everything
/// Client options
/// </summary>
public class BaseOptions
public abstract class ClientOptions
{
internal event Action? OnLoggingChanged;
private LogLevel _logLevel = LogLevel.Information;
/// <summary>
/// The minimum log level to output
/// </summary>
public LogLevel LogLevel { get; set; } = LogLevel.Information;
public LogLevel LogLevel
{
get => _logLevel;
set
{
_logLevel = value;
OnLoggingChanged?.Invoke();
}
}
private List<ILogger> _logWriters = new List<ILogger> { new DebugLogger() };
/// <summary>
/// The log writers
/// </summary>
public List<ILogger> LogWriters { get; set; } = new List<ILogger> { new DebugLogger() };
/// <summary>
/// If true, the CallResult and DataEvent objects will also include the originally received json data in the OriginalData property
/// </summary>
public bool OutputOriginalData { get; set; } = false;
/// <summary>
/// ctor
/// </summary>
public BaseOptions(): this(null)
public List<ILogger> LogWriters
{
get => _logWriters;
set
{
_logWriters = value;
OnLoggingChanged?.Invoke();
}
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseOptions">Copy options from these options to the new options</param>
public BaseOptions(BaseOptions? baseOptions)
{
if (baseOptions == null)
return;
LogLevel = baseOptions.LogLevel;
LogWriters = baseOptions.LogWriters.ToList();
OutputOriginalData = baseOptions.OutputOriginalData;
}
/// <inheritdoc />
public override string ToString()
{
return $"LogLevel: {LogLevel}, Writers: {LogWriters.Count}, OutputOriginalData: {OutputOriginalData}";
}
}
/// <summary>
/// Client options, for both the socket and rest clients
/// </summary>
public class BaseClientOptions : BaseOptions
{
/// <summary>
/// Proxy to use when connecting
/// </summary>
public ApiProxy? Proxy { get; set; }
/// <summary>
/// Api credentials to be used for signing requests to private endpoints. These credentials will be used for each API in the client, unless overriden in the API options
/// </summary>
/// The api credentials used for signing requests to this API.
/// </summary>
public ApiCredentials? ApiCredentials { get; set; }
/// <summary>
/// ctor
/// </summary>
public BaseClientOptions() : this(null)
public ClientOptions()
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseOptions">Copy options from these options to the new options</param>
public BaseClientOptions(BaseClientOptions? baseOptions) : base(baseOptions)
/// <param name="clientOptions">Copy values for the provided options</param>
public ClientOptions(ClientOptions? clientOptions)
{
if (baseOptions == null)
if (clientOptions == null)
return;
Proxy = baseOptions.Proxy;
ApiCredentials = baseOptions.ApiCredentials?.Copy();
LogLevel = clientOptions.LogLevel;
LogWriters = clientOptions.LogWriters.ToList();
Proxy = clientOptions.Proxy;
ApiCredentials = clientOptions.ApiCredentials?.Copy();
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseOptions">Copy values for the provided options</param>
/// <param name="newValues">Copy values for the provided options</param>
internal ClientOptions(ClientOptions baseOptions, ClientOptions? newValues)
{
Proxy = newValues?.Proxy ?? baseOptions.Proxy;
LogLevel = baseOptions.LogLevel;
LogWriters = baseOptions.LogWriters.ToList();
ApiCredentials = newValues?.ApiCredentials?.Copy() ?? baseOptions.ApiCredentials?.Copy();
}
/// <inheritdoc />
public override string ToString()
{
return $"{base.ToString()}, Proxy: {(Proxy == null ? "-" : Proxy.Host)}, Base.ApiCredentials: {(ApiCredentials == null ? "-" : "set")}";
}
}
/// <summary>
/// Rest client options
/// </summary>
public class BaseRestClientOptions : BaseClientOptions
{
/// <summary>
/// The time the server has to respond to a request before timing out
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Http client to use. If a HttpClient is provided in this property the RequestTimeout and Proxy options provided in these options will be ignored in requests and should be set on the provided HttpClient instance
/// </summary>
public HttpClient? HttpClient { get; set; }
/// <summary>
/// ctor
/// </summary>
public BaseRestClientOptions(): this(null)
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseOptions">Copy options from these options to the new options</param>
public BaseRestClientOptions(BaseRestClientOptions? baseOptions): base(baseOptions)
{
if (baseOptions == null)
return;
HttpClient = baseOptions.HttpClient;
RequestTimeout = baseOptions.RequestTimeout;
}
/// <inheritdoc />
public override string ToString()
{
return $"{base.ToString()}, RequestTimeout: {RequestTimeout:c}, HttpClient: {(HttpClient == null ? "-" : "set")}";
}
}
/// <summary>
/// Socket client options
/// </summary>
public class BaseSocketClientOptions : BaseClientOptions
{
/// <summary>
/// Whether or not the socket should automatically reconnect when losing connection
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Time to wait between reconnect attempts
/// </summary>
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// The maximum number of times to try to reconnect, default null will retry indefinitely
/// </summary>
public int? MaxReconnectTries { get; set; }
/// <summary>
/// The maximum number of times to try to resubscribe after reconnecting
/// </summary>
public int? MaxResubscribeTries { get; set; }
/// <summary>
/// Max number of concurrent resubscription tasks per socket after reconnecting a socket
/// </summary>
public int MaxConcurrentResubscriptionsPerSocket { get; set; } = 5;
/// <summary>
/// The max time to wait for a response after sending a request on the socket before giving a timeout
/// </summary>
public TimeSpan SocketResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// The max time of not receiving any data after which the connection is assumed to be dropped. This can only be used for socket connections where a steady flow of data is expected,
/// for example when the server sends intermittent ping requests
/// </summary>
public TimeSpan SocketNoDataTimeout { get; set; }
/// <summary>
/// The amount of subscriptions that should be made on a single socket connection. Not all API's support multiple subscriptions on a single socket.
/// Setting this to a higher number increases subscription speed because not every subscription needs to connect to the server, but having more subscriptions on a
/// single connection will also increase the amount of traffic on that single connection, potentially leading to issues.
/// </summary>
public int? SocketSubscriptionsCombineTarget { get; set; }
/// <summary>
/// ctor
/// </summary>
public BaseSocketClientOptions(): this(null)
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseOptions">Copy options from these options to the new options</param>
public BaseSocketClientOptions(BaseSocketClientOptions? baseOptions): base(baseOptions)
{
if (baseOptions == null)
return;
AutoReconnect = baseOptions.AutoReconnect;
ReconnectInterval = baseOptions.ReconnectInterval;
MaxReconnectTries = baseOptions.MaxReconnectTries;
MaxResubscribeTries = baseOptions.MaxResubscribeTries;
MaxConcurrentResubscriptionsPerSocket = baseOptions.MaxConcurrentResubscriptionsPerSocket;
SocketResponseTimeout = baseOptions.SocketResponseTimeout;
SocketNoDataTimeout = baseOptions.SocketNoDataTimeout;
SocketSubscriptionsCombineTarget = baseOptions.SocketSubscriptionsCombineTarget;
}
/// <inheritdoc />
public override string ToString()
{
return $"{base.ToString()}, AutoReconnect: {AutoReconnect}, ReconnectInterval: {ReconnectInterval}, MaxReconnectTries: {MaxReconnectTries}, MaxResubscribeTries: {MaxResubscribeTries}, MaxConcurrentResubscriptionsPerSocket: {MaxConcurrentResubscriptionsPerSocket}, SocketResponseTimeout: {SocketResponseTimeout:c}, SocketNoDataTimeout: {SocketNoDataTimeout}, SocketSubscriptionsCombineTarget: {SocketSubscriptionsCombineTarget}";
return $"LogLevel: {LogLevel}, Writers: {LogWriters.Count}, Proxy: {(Proxy == null ? "-" : Proxy.Host)}";
}
}
@@ -227,6 +101,11 @@ namespace CryptoExchange.Net.Objects
/// </summary>
public class ApiClientOptions
{
/// <summary>
/// If true, the CallResult and DataEvent objects will also include the originally received json data in the OriginalData property
/// </summary>
public bool OutputOriginalData { get; set; } = false;
/// <summary>
/// The base address of the API
/// </summary>
@@ -264,12 +143,13 @@ namespace CryptoExchange.Net.Objects
{
BaseAddress = newValues?.BaseAddress ?? baseOptions.BaseAddress;
ApiCredentials = newValues?.ApiCredentials?.Copy() ?? baseOptions.ApiCredentials?.Copy();
OutputOriginalData = newValues?.OutputOriginalData ?? baseOptions.OutputOriginalData;
}
/// <inheritdoc />
public override string ToString()
{
return $"Credentials: {(ApiCredentials == null ? "-" : "Set")}, BaseAddress: {BaseAddress}";
return $"OutputOriginalData: {OutputOriginalData}, Credentials: {(ApiCredentials == null ? "-" : "Set")}, BaseAddress: {BaseAddress}";
}
}
@@ -278,6 +158,16 @@ namespace CryptoExchange.Net.Objects
/// </summary>
public class RestApiClientOptions: ApiClientOptions
{
/// <summary>
/// The time the server has to respond to a request before timing out
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Http client to use. If a HttpClient is provided in this property the RequestTimeout and Proxy options provided in these options will be ignored in requests and should be set on the provided HttpClient instance
/// </summary>
public HttpClient? HttpClient { get; set; }
/// <summary>
/// List of rate limiters to use
/// </summary>
@@ -320,6 +210,8 @@ namespace CryptoExchange.Net.Objects
/// <param name="newValues">Copy values for the provided options</param>
public RestApiClientOptions(RestApiClientOptions baseOn, RestApiClientOptions? newValues): base(baseOn, newValues)
{
HttpClient = newValues?.HttpClient ?? baseOn.HttpClient;
RequestTimeout = newValues == default ? baseOn.RequestTimeout : newValues.RequestTimeout;
RateLimitingBehaviour = newValues?.RateLimitingBehaviour ?? baseOn.RateLimitingBehaviour;
AutoTimestamp = newValues?.AutoTimestamp ?? baseOn.AutoTimestamp;
TimestampRecalculationInterval = newValues?.TimestampRecalculationInterval ?? baseOn.TimestampRecalculationInterval;
@@ -329,14 +221,104 @@ namespace CryptoExchange.Net.Objects
/// <inheritdoc />
public override string ToString()
{
return $"{base.ToString()}, RateLimiters: {RateLimiters?.Count}, RateLimitBehaviour: {RateLimitingBehaviour}, AutoTimestamp: {AutoTimestamp}, TimestampRecalculationInterval: {TimestampRecalculationInterval}";
return $"{base.ToString()}, RequestTimeout: {RequestTimeout:c}, HttpClient: {(HttpClient == null ? "-" : "set")}, RateLimiters: {RateLimiters?.Count}, RateLimitBehaviour: {RateLimitingBehaviour}, AutoTimestamp: {AutoTimestamp}, TimestampRecalculationInterval: {TimestampRecalculationInterval}";
}
}
/// <summary>
/// Rest API client options
/// </summary>
public class SocketApiClientOptions : ApiClientOptions
{
/// <summary>
/// Whether or not the socket should automatically reconnect when losing connection
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Time to wait between reconnect attempts
/// </summary>
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Max number of concurrent resubscription tasks per socket after reconnecting a socket
/// </summary>
public int MaxConcurrentResubscriptionsPerSocket { get; set; } = 5;
/// <summary>
/// The max time to wait for a response after sending a request on the socket before giving a timeout
/// </summary>
public TimeSpan SocketResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// The max time of not receiving any data after which the connection is assumed to be dropped. This can only be used for socket connections where a steady flow of data is expected,
/// for example when the server sends intermittent ping requests
/// </summary>
public TimeSpan SocketNoDataTimeout { get; set; }
/// <summary>
/// The amount of subscriptions that should be made on a single socket connection. Not all API's support multiple subscriptions on a single socket.
/// Setting this to a higher number increases subscription speed because not every subscription needs to connect to the server, but having more subscriptions on a
/// single connection will also increase the amount of traffic on that single connection, potentially leading to issues.
/// </summary>
public int? SocketSubscriptionsCombineTarget { get; set; }
/// <summary>
/// The max amount of connections to make to the server. Can be used for API's which only allow a certain number of connections. Changing this to a high value might cause issues.
/// </summary>
public int? MaxSocketConnections { get; set; }
/// <summary>
/// The time to wait after connecting a socket before sending messages. Can be used for API's which will rate limit if you subscribe directly after connecting.
/// </summary>
public TimeSpan DelayAfterConnect { get; set; } = TimeSpan.Zero;
/// <summary>
/// ctor
/// </summary>
public SocketApiClientOptions()
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseAddress">Base address for the API</param>
public SocketApiClientOptions(string baseAddress) : base(baseAddress)
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="baseOptions">Copy values for the provided options</param>
/// <param name="newValues">Copy values for the provided options</param>
public SocketApiClientOptions(SocketApiClientOptions baseOptions, SocketApiClientOptions? newValues) : base(baseOptions, newValues)
{
if (baseOptions == null)
return;
AutoReconnect = newValues?.AutoReconnect ?? baseOptions.AutoReconnect;
ReconnectInterval = newValues?.ReconnectInterval ?? baseOptions.ReconnectInterval;
MaxConcurrentResubscriptionsPerSocket = newValues?.MaxConcurrentResubscriptionsPerSocket ?? baseOptions.MaxConcurrentResubscriptionsPerSocket;
SocketResponseTimeout = newValues?.SocketResponseTimeout ?? baseOptions.SocketResponseTimeout;
SocketNoDataTimeout = newValues?.SocketNoDataTimeout ?? baseOptions.SocketNoDataTimeout;
SocketSubscriptionsCombineTarget = newValues?.SocketSubscriptionsCombineTarget ?? baseOptions.SocketSubscriptionsCombineTarget;
MaxSocketConnections = newValues?.MaxSocketConnections ?? baseOptions.MaxSocketConnections;
DelayAfterConnect = newValues?.DelayAfterConnect ?? baseOptions.DelayAfterConnect;
}
/// <inheritdoc />
public override string ToString()
{
return $"{base.ToString()}, AutoReconnect: {AutoReconnect}, ReconnectInterval: {ReconnectInterval}, MaxConcurrentResubscriptionsPerSocket: {MaxConcurrentResubscriptionsPerSocket}, SocketResponseTimeout: {SocketResponseTimeout:c}, SocketNoDataTimeout: {SocketNoDataTimeout}, SocketSubscriptionsCombineTarget: {SocketSubscriptionsCombineTarget}, MaxSocketConnections: {MaxSocketConnections}";
}
}
/// <summary>
/// Base for order book options
/// </summary>
public class OrderBookOptions : BaseOptions
public class OrderBookOptions : ClientOptions
{
/// <summary>
/// Whether or not checksum validation is enabled. Default is true, disabling will ignore checksum messages.
+38 -22
View File
@@ -40,7 +40,7 @@ namespace CryptoExchange.Net.OrderBook
set { } }
}
private static readonly ISymbolOrderBookEntry emptySymbolOrderBookEntry = new EmptySymbolOrderBookEntry();
private static readonly ISymbolOrderBookEntry _emptySymbolOrderBookEntry = new EmptySymbolOrderBookEntry();
/// <summary>
@@ -167,7 +167,7 @@ namespace CryptoExchange.Net.OrderBook
get
{
lock (_bookLock)
return bids.FirstOrDefault().Value ?? emptySymbolOrderBookEntry;
return bids.FirstOrDefault().Value ?? _emptySymbolOrderBookEntry;
}
}
@@ -177,7 +177,7 @@ namespace CryptoExchange.Net.OrderBook
get
{
lock (_bookLock)
return asks.FirstOrDefault().Value ?? emptySymbolOrderBookEntry;
return asks.FirstOrDefault().Value ?? _emptySymbolOrderBookEntry;
}
}
@@ -258,24 +258,32 @@ namespace CryptoExchange.Net.OrderBook
}
_subscription = startResult.Data;
_subscription.ConnectionLost += () =>
{
log.Write(LogLevel.Warning, $"{Id} order book {Symbol} connection lost");
Status = OrderBookStatus.Reconnecting;
Reset();
};
_subscription.ConnectionClosed += () =>
{
log.Write(LogLevel.Warning, $"{Id} order book {Symbol} disconnected");
Status = OrderBookStatus.Disconnected;
_ = StopAsync();
};
_subscription.ConnectionLost += HandleConnectionLost;
_subscription.ConnectionClosed += HandleConnectionClosed;
_subscription.ConnectionRestored += HandleConnectionRestored;
_subscription.ConnectionRestored += async time => await ResyncAsync().ConfigureAwait(false);
Status = OrderBookStatus.Synced;
return new CallResult<bool>(true);
}
private void HandleConnectionLost() {
log.Write(LogLevel.Warning, $"{Id} order book {Symbol} connection lost");
if (Status != OrderBookStatus.Disposed) {
Status = OrderBookStatus.Reconnecting;
Reset();
}
}
private void HandleConnectionClosed() {
log.Write(LogLevel.Warning, $"{Id} order book {Symbol} disconnected");
Status = OrderBookStatus.Disconnected;
_ = StopAsync();
}
private async void HandleConnectionRestored(TimeSpan _) {
await ResyncAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task StopAsync()
{
@@ -286,8 +294,12 @@ namespace CryptoExchange.Net.OrderBook
if (_processTask != null)
await _processTask.ConfigureAwait(false);
if (_subscription != null)
if (_subscription != null) {
await _subscription.CloseAsync().ConfigureAwait(false);
_subscription.ConnectionLost -= HandleConnectionLost;
_subscription.ConnectionClosed -= HandleConnectionClosed;
_subscription.ConnectionRestored -= HandleConnectionRestored;
}
log.Write(LogLevel.Trace, $"{Id} order book {Symbol} stopped");
}
@@ -483,7 +495,7 @@ namespace CryptoExchange.Net.OrderBook
/// <param name="timeout">Max wait time</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
protected async Task<CallResult<bool>> WaitForSetOrderBookAsync(int timeout, CancellationToken ct)
protected async Task<CallResult<bool>> WaitForSetOrderBookAsync(TimeSpan timeout, CancellationToken ct)
{
var startWait = DateTime.UtcNow;
while (!bookSet && Status == OrderBookStatus.Syncing)
@@ -491,12 +503,12 @@ namespace CryptoExchange.Net.OrderBook
if(ct.IsCancellationRequested)
return new CallResult<bool>(new CancellationRequestedError());
if ((DateTime.UtcNow - startWait).TotalMilliseconds > timeout)
if (DateTime.UtcNow - startWait > timeout)
return new CallResult<bool>(new ServerError("Timeout while waiting for data"));
try
{
await Task.Delay(10, ct).ConfigureAwait(false);
await Task.Delay(50, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{ }
@@ -569,7 +581,9 @@ namespace CryptoExchange.Net.OrderBook
var (bestBid, bestAsk) = BestOffers;
if (bestBid.Price != prevBestBid.Price || bestBid.Quantity != prevBestBid.Quantity ||
bestAsk.Price != prevBestAsk.Price || bestAsk.Quantity != prevBestAsk.Quantity)
{
OnBestOffersChanged?.Invoke((bestBid, bestAsk));
}
}
private void Reset()
@@ -601,13 +615,13 @@ namespace CryptoExchange.Net.OrderBook
private async Task ProcessQueue()
{
while (Status != OrderBookStatus.Disconnected)
while (Status != OrderBookStatus.Disconnected && Status != OrderBookStatus.Disposed)
{
await _queueEvent.WaitAsync().ConfigureAwait(false);
while (_processQueue.TryDequeue(out var item))
{
if (Status == OrderBookStatus.Disconnected)
if (Status == OrderBookStatus.Disconnected || Status == OrderBookStatus.Disposed)
break;
if (_stopProcessing)
@@ -740,7 +754,9 @@ namespace CryptoExchange.Net.OrderBook
_ = _subscription!.ReconnectAsync();
}
else
{
await ResyncAsync().ConfigureAwait(false);
}
});
}
@@ -5,13 +5,11 @@ using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Security.Authentication;
using System.Text;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -22,29 +20,38 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public class CryptoExchangeWebSocketClient : IWebsocket
{
internal static int lastStreamId;
private static readonly object streamIdLock = new object();
enum ProcessState
{
Idle,
Processing,
WaitingForClose,
Reconnecting
}
internal static int lastStreamId;
private static readonly object streamIdLock = new();
private ClientWebSocket _socket;
private Task? _sendTask;
private Task? _receiveTask;
private Task? _timeoutTask;
private readonly AsyncResetEvent _sendEvent;
private readonly ConcurrentQueue<byte[]> _sendBuffer;
private readonly IDictionary<string, string> cookies;
private readonly IDictionary<string, string> headers;
private CancellationTokenSource _ctsSource;
private bool _closing;
private bool _startedSent;
private bool _startedReceive;
private readonly SemaphoreSlim _closeSem;
private readonly List<DateTime> _outgoingMessages;
private ClientWebSocket _socket;
private CancellationTokenSource _ctsSource;
private DateTime _lastReceivedMessagesUpdate;
private Task? _processTask;
private Task? _closeTask;
private bool _stopRequested;
private bool _disposed;
private ProcessState _processState;
private DateTime _lastReconnectTime;
/// <summary>
/// Received messages, the size and the timstamp
/// </summary>
protected readonly List<ReceiveItem> _receivedMessages;
/// <summary>
/// Received messages lock
/// </summary>
@@ -53,82 +60,27 @@ namespace CryptoExchange.Net.Sockets
/// <summary>
/// Log
/// </summary>
protected Log log;
/// <summary>
/// Handlers for when an error happens on the socket
/// </summary>
protected readonly List<Action<Exception>> errorHandlers = new List<Action<Exception>>();
/// <summary>
/// Handlers for when the socket connection is opened
/// </summary>
protected readonly List<Action> openHandlers = new List<Action>();
/// <summary>
/// Handlers for when the connection is closed
/// </summary>
protected readonly List<Action> closeHandlers = new List<Action>();
/// <summary>
/// Handlers for when a message is received
/// </summary>
protected readonly List<Action<string>> messageHandlers = new List<Action<string>>();
protected Log _log;
/// <inheritdoc />
public int Id { get; }
/// <inheritdoc />
public string? Origin { get; set; }
/// <inheritdoc />
public bool Reconnecting { get; set; }
public WebSocketParameters Parameters { get; }
/// <summary>
/// The timestamp this socket has been active for the last time
/// </summary>
public DateTime LastActionTime { get; private set; }
/// <summary>
/// Delegate used for processing byte data received from socket connections before it is processed by handlers
/// </summary>
public Func<byte[], string>? DataInterpreterBytes { get; set; }
/// <summary>
/// Delegate used for processing string data received from socket connections before it is processed by handlers
/// </summary>
public Func<string, string>? DataInterpreterString { get; set; }
/// <inheritdoc />
public string Url { get; }
public Uri Uri => Parameters.Uri;
/// <inheritdoc />
public bool IsClosed => _socket.State == WebSocketState.Closed;
/// <inheritdoc />
public bool IsOpen => _socket.State == WebSocketState.Open && !_closing;
/// <summary>
/// Ssl protocols supported. NOT USED BY THIS IMPLEMENTATION
/// </summary>
public SslProtocols SSLProtocols { get; set; }
private Encoding _encoding = Encoding.UTF8;
/// <inheritdoc />
public Encoding? Encoding
{
get => _encoding;
set
{
if(value != null)
_encoding = value;
}
}
/// <summary>
/// The max amount of outgoing messages per second
/// </summary>
public int? RatelimitPerSecond { get; set; }
/// <inheritdoc />
public TimeSpan Timeout { get; set; }
public bool IsOpen => _socket.State == WebSocketState.Open && !_ctsSource.IsCancellationRequested;
/// <inheritdoc />
public double IncomingKbps
@@ -148,57 +100,31 @@ namespace CryptoExchange.Net.Sockets
}
/// <inheritdoc />
public event Action OnClose
{
add => closeHandlers.Add(value);
remove => closeHandlers.Remove(value);
}
public event Action? OnClose;
/// <inheritdoc />
public event Action<string> OnMessage
{
add => messageHandlers.Add(value);
remove => messageHandlers.Remove(value);
}
public event Action<string>? OnMessage;
/// <inheritdoc />
public event Action<Exception> OnError
{
add => errorHandlers.Add(value);
remove => errorHandlers.Remove(value);
}
public event Action<Exception>? OnError;
/// <inheritdoc />
public event Action OnOpen
{
add => openHandlers.Add(value);
remove => openHandlers.Remove(value);
}
public event Action? OnOpen;
/// <inheritdoc />
public event Action? OnReconnecting;
/// <inheritdoc />
public event Action? OnReconnected;
/// <inheritdoc />
public Func<Task<Uri?>>? GetReconnectionUrl { get; set; }
/// <summary>
/// ctor
/// </summary>
/// <param name="log">The log object to use</param>
/// <param name="url">The url the socket should connect to</param>
public CryptoExchangeWebSocketClient(Log log, string url) : this(log, url, new Dictionary<string, string>(), new Dictionary<string, string>())
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="log">The log object to use</param>
/// <param name="url">The url the socket should connect to</param>
/// <param name="cookies">Cookies to sent in the socket connection request</param>
/// <param name="headers">Headers to sent in the socket connection request</param>
public CryptoExchangeWebSocketClient(Log log, string url, IDictionary<string, string> cookies, IDictionary<string, string> headers)
/// <param name="websocketParameters">The parameters for this socket</param>
public CryptoExchangeWebSocketClient(Log log, WebSocketParameters websocketParameters)
{
Id = NextStreamId();
this.log = log;
Url = url;
this.cookies = cookies;
this.headers = headers;
_log = log;
Parameters = websocketParameters;
_outgoingMessages = new List<DateTime>();
_receivedMessages = new List<ReceiveItem>();
_sendEvent = new AsyncResetEvent();
@@ -206,106 +132,238 @@ namespace CryptoExchange.Net.Sockets
_ctsSource = new CancellationTokenSource();
_receivedMessagesLock = new object();
_closeSem = new SemaphoreSlim(1, 1);
_socket = CreateSocket();
}
/// <inheritdoc />
public virtual void SetProxy(ApiProxy proxy)
{
_socket.Options.Proxy = new WebProxy(proxy.Host, proxy.Port);
if (proxy.Login != null)
_socket.Options.Proxy.Credentials = new NetworkCredential(proxy.Login, proxy.Password);
}
/// <inheritdoc />
public virtual async Task<bool> ConnectAsync()
{
log.Write(LogLevel.Debug, $"Socket {Id} connecting");
if (!await ConnectInternalAsync().ConfigureAwait(false))
return false;
OnOpen?.Invoke();
_processTask = ProcessAsync();
return true;
}
/// <summary>
/// Create the socket object
/// </summary>
private ClientWebSocket CreateSocket()
{
var cookieContainer = new CookieContainer();
foreach (var cookie in Parameters.Cookies)
cookieContainer.Add(new Cookie(cookie.Key, cookie.Value));
var socket = new ClientWebSocket();
try
{
using CancellationTokenSource tcs = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _socket.ConnectAsync(new Uri(Url), tcs.Token).ConfigureAwait(false);
Handle(openHandlers);
socket.Options.Cookies = cookieContainer;
foreach (var header in Parameters.Headers)
socket.Options.SetRequestHeader(header.Key, header.Value);
socket.Options.KeepAliveInterval = Parameters.KeepAliveInterval ?? TimeSpan.Zero;
socket.Options.SetBuffer(65536, 65536); // Setting it to anything bigger than 65536 throws an exception in .net framework
if (Parameters.Proxy != null)
SetProxy(socket, Parameters.Proxy);
}
catch (PlatformNotSupportedException)
{
// Options are not supported on certain platforms (WebAssembly for instance)
// best we can do it try to connect without setting options.
}
return socket;
}
private async Task<bool> ConnectInternalAsync()
{
_log.Write(LogLevel.Debug, $"Socket {Id} connecting");
try
{
using CancellationTokenSource tcs = new(TimeSpan.FromSeconds(10));
await _socket.ConnectAsync(Uri, tcs.Token).ConfigureAwait(false);
}
catch (Exception e)
{
log.Write(LogLevel.Debug, $"Socket {Id} connection failed: " + e.ToLogString());
_log.Write(LogLevel.Debug, $"Socket {Id} connection failed: " + e.ToLogString());
return false;
}
log.Write(LogLevel.Trace, $"Socket {Id} connection succeeded, starting communication");
_sendTask = Task.Factory.StartNew(SendLoopAsync, default, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();
_receiveTask = Task.Factory.StartNew(ReceiveLoopAsync, default, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap();
if (Timeout != default)
_timeoutTask = Task.Run(CheckTimeoutAsync);
_log.Write(LogLevel.Debug, $"Socket {Id} connected to {Uri}");
return true;
}
var sw = Stopwatch.StartNew();
while (!_startedSent || !_startedReceive)
/// <inheritdoc />
private async Task ProcessAsync()
{
while (!_stopRequested)
{
// Wait for the tasks to have actually started
await Task.Delay(10).ConfigureAwait(false);
_log.Write(LogLevel.Debug, $"Socket {Id} starting processing tasks");
_processState = ProcessState.Processing;
var sendTask = SendLoopAsync();
var receiveTask = ReceiveLoopAsync();
var timeoutTask = Parameters.Timeout != null && Parameters.Timeout > TimeSpan.FromSeconds(0) ? CheckTimeoutAsync() : Task.CompletedTask;
await Task.WhenAll(sendTask, receiveTask, timeoutTask).ConfigureAwait(false);
_log.Write(LogLevel.Debug, $"Socket {Id} processing tasks finished");
if(sw.ElapsedMilliseconds > 5000)
_processState = ProcessState.WaitingForClose;
while (_closeTask == null)
await Task.Delay(50).ConfigureAwait(false);
await _closeTask.ConfigureAwait(false);
_closeTask = null;
if (!Parameters.AutoReconnect)
{
_ = _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", default);
log.Write(LogLevel.Debug, $"Socket {Id} startup interupted");
return false;
_processState = ProcessState.Idle;
OnClose?.Invoke();
return;
}
if (!_stopRequested)
{
_processState = ProcessState.Reconnecting;
OnReconnecting?.Invoke();
}
var sinceLastReconnect = DateTime.UtcNow - _lastReconnectTime;
if (sinceLastReconnect < Parameters.ReconnectInterval)
await Task.Delay(Parameters.ReconnectInterval - sinceLastReconnect).ConfigureAwait(false);
while (!_stopRequested)
{
_log.Write(LogLevel.Debug, $"Socket {Id} attempting to reconnect");
var task = GetReconnectionUrl?.Invoke();
if (task != null)
{
var reconnectUri = await task.ConfigureAwait(false);
if (reconnectUri != null && Parameters.Uri != reconnectUri)
{
_log.Write(LogLevel.Debug, $"Socket {Id} reconnect URI set to {reconnectUri}");
Parameters.Uri = reconnectUri;
}
}
_socket = CreateSocket();
_ctsSource.Dispose();
_ctsSource = new CancellationTokenSource();
while (_sendBuffer.TryDequeue(out _)) { } // Clear send buffer
var connected = await ConnectInternalAsync().ConfigureAwait(false);
if (!connected)
{
await Task.Delay(Parameters.ReconnectInterval).ConfigureAwait(false);
continue;
}
_lastReconnectTime = DateTime.UtcNow;
OnReconnected?.Invoke();
break;
}
}
log.Write(LogLevel.Debug, $"Socket {Id} connected to {Url}");
return true;
_processState = ProcessState.Idle;
}
/// <inheritdoc />
public virtual void Send(string data)
{
if (_closing)
throw new InvalidOperationException($"Socket {Id} Can't send data when socket is not connected");
if (_ctsSource.IsCancellationRequested)
return;
var bytes = _encoding.GetBytes(data);
log.Write(LogLevel.Trace, $"Socket {Id} Adding {bytes.Length} to sent buffer");
var bytes = Parameters.Encoding.GetBytes(data);
_log.Write(LogLevel.Trace, $"Socket {Id} Adding {bytes.Length} to sent buffer");
_sendBuffer.Enqueue(bytes);
_sendEvent.Set();
}
/// <inheritdoc />
public virtual async Task CloseAsync()
public virtual async Task ReconnectAsync()
{
log.Write(LogLevel.Debug, $"Socket {Id} closing");
await CloseInternalAsync(true, true).ConfigureAwait(false);
}
/// <summary>
/// Internal close method, will wait for each task to complete to gracefully close
/// </summary>
/// <param name="waitSend"></param>
/// <param name="waitReceive"></param>
/// <returns></returns>
private async Task CloseInternalAsync(bool waitSend, bool waitReceive)
{
if (_closing)
if (_processState != ProcessState.Processing && IsOpen)
return;
_closing = true;
var tasksToAwait = new List<Task>();
if (_socket.State == WebSocketState.Open)
tasksToAwait.Add(_socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing", default));
_log.Write(LogLevel.Debug, $"Socket {Id} reconnect requested");
_closeTask = CloseInternalAsync();
await _closeTask.ConfigureAwait(false);
}
/// <inheritdoc />
public virtual async Task CloseAsync()
{
await _closeSem.WaitAsync().ConfigureAwait(false);
_stopRequested = true;
try
{
if (_closeTask?.IsCompleted == false)
{
_log.Write(LogLevel.Debug, $"Socket {Id} CloseAsync() waiting for existing close task");
await _closeTask.ConfigureAwait(false);
return;
}
if (!IsOpen)
{
_log.Write(LogLevel.Debug, $"Socket {Id} CloseAsync() socket not open");
return;
}
_log.Write(LogLevel.Debug, $"Socket {Id} closing");
_closeTask = CloseInternalAsync();
}
finally
{
_closeSem.Release();
}
await _closeTask.ConfigureAwait(false);
if(_processTask != null)
await _processTask.ConfigureAwait(false);
OnClose?.Invoke();
_log.Write(LogLevel.Debug, $"Socket {Id} closed");
}
/// <summary>
/// Internal close method
/// </summary>
/// <returns></returns>
private async Task CloseInternalAsync()
{
if (_disposed)
return;
//_closeState = CloseState.Closing;
_ctsSource.Cancel();
_sendEvent.Set();
if (waitSend)
tasksToAwait.Add(_sendTask!);
if (waitReceive)
tasksToAwait.Add(_receiveTask!);
if (_timeoutTask != null)
tasksToAwait.Add(_timeoutTask);
log.Write(LogLevel.Trace, $"Socket {Id} waiting for communication loops to finish");
await Task.WhenAll(tasksToAwait).ConfigureAwait(false);
log.Write(LogLevel.Debug, $"Socket {Id} closed");
Handle(closeHandlers);
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing", default).ConfigureAwait(false);
}
catch (Exception)
{
// Can sometimes throw an exception when socket is in aborted state due to timing
// Websocket is set to Aborted state when the cancelation token is set during SendAsync/ReceiveAsync
// So socket might go to aborted state, might still be open
}
}
else if(_socket.State == WebSocketState.CloseReceived)
{
try
{
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", default).ConfigureAwait(false);
}
catch (Exception)
{
// Can sometimes throw an exception when socket is in aborted state due to timing
// Websocket is set to Aborted state when the cancelation token is set during SendAsync/ReceiveAsync
// So socket might go to aborted state, might still be open
}
}
}
/// <summary>
@@ -313,45 +371,14 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public void Dispose()
{
log.Write(LogLevel.Debug, $"Socket {Id} disposing");
if (_disposed)
return;
_log.Write(LogLevel.Debug, $"Socket {Id} disposing");
_disposed = true;
_socket.Dispose();
_ctsSource.Dispose();
errorHandlers.Clear();
openHandlers.Clear();
closeHandlers.Clear();
messageHandlers.Clear();
log.Write(LogLevel.Trace, $"Socket {Id} disposed");
}
/// <inheritdoc />
public void Reset()
{
log.Write(LogLevel.Debug, $"Socket {Id} resetting");
_ctsSource = new CancellationTokenSource();
_closing = false;
while (_sendBuffer.TryDequeue(out _)) { } // Clear send buffer
_socket = CreateSocket();
}
/// <summary>
/// Create the socket object
/// </summary>
private ClientWebSocket CreateSocket()
{
var cookieContainer = new CookieContainer();
foreach (var cookie in cookies)
cookieContainer.Add(new Cookie(cookie.Key, cookie.Value));
var socket = new ClientWebSocket();
socket.Options.Cookies = cookieContainer;
foreach (var header in headers)
socket.Options.SetRequestHeader(header.Key, header.Value);
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(10);
socket.Options.SetBuffer(65536, 65536); // Setting it to anything bigger than 65536 throws an exception in .net framework
return socket;
_log.Write(LogLevel.Trace, $"Socket {Id} disposed");
}
/// <summary>
@@ -360,40 +387,39 @@ namespace CryptoExchange.Net.Sockets
/// <returns></returns>
private async Task SendLoopAsync()
{
_startedSent = true;
try
{
while (true)
{
if (_closing)
if (_ctsSource.IsCancellationRequested)
break;
await _sendEvent.WaitAsync().ConfigureAwait(false);
if (_closing)
if (_ctsSource.IsCancellationRequested)
break;
while (_sendBuffer.TryDequeue(out var data))
{
if (RatelimitPerSecond != null)
if (Parameters.RatelimitPerSecond != null)
{
// Wait for rate limit
DateTime? start = null;
while (MessagesSentLastSecond() >= RatelimitPerSecond)
while (MessagesSentLastSecond() >= Parameters.RatelimitPerSecond)
{
start ??= DateTime.UtcNow;
await Task.Delay(10).ConfigureAwait(false);
await Task.Delay(50).ConfigureAwait(false);
}
if (start != null)
log.Write(LogLevel.Trace, $"Socket {Id} sent delayed {Math.Round((DateTime.UtcNow - start.Value).TotalMilliseconds)}ms because of rate limit");
_log.Write(LogLevel.Debug, $"Socket {Id} sent delayed {Math.Round((DateTime.UtcNow - start.Value).TotalMilliseconds)}ms because of rate limit");
}
try
{
await _socket.SendAsync(new ArraySegment<byte>(data, 0, data.Length), WebSocketMessageType.Text, true, _ctsSource.Token).ConfigureAwait(false);
_outgoingMessages.Add(DateTime.UtcNow);
log.Write(LogLevel.Trace, $"Socket {Id} sent {data.Length} bytes");
_log.Write(LogLevel.Trace, $"Socket {Id} sent {data.Length} bytes");
}
catch (OperationCanceledException)
{
@@ -402,9 +428,10 @@ namespace CryptoExchange.Net.Sockets
}
catch (Exception ioe)
{
// Connection closed unexpectedly, .NET framework
Handle(errorHandlers, ioe);
_ = Task.Run(async () => await CloseInternalAsync(false, true).ConfigureAwait(false));
// Connection closed unexpectedly, .NET framework
OnError?.Invoke(ioe);
if (_closeTask?.IsCompleted != false)
_closeTask = CloseInternalAsync();
break;
}
}
@@ -415,13 +442,13 @@ namespace CryptoExchange.Net.Sockets
// Because this is running in a separate task and not awaited until the socket gets closed
// any exception here will crash the send processing, but do so silently unless the socket get's stopped.
// Make sure we at least let the owner know there was an error
Handle(errorHandlers, e);
_log.Write(LogLevel.Warning, $"Socket {Id} Send loop stopped with exception");
OnError?.Invoke(e);
throw;
}
finally
{
log.Write(LogLevel.Trace, $"Socket {Id} Send loop finished");
_startedSent = false;
_log.Write(LogLevel.Debug, $"Socket {Id} Send loop finished");
}
}
@@ -431,14 +458,13 @@ namespace CryptoExchange.Net.Sockets
/// <returns></returns>
private async Task ReceiveLoopAsync()
{
_startedReceive = true;
var buffer = new ArraySegment<byte>(new byte[65536]);
var received = 0;
try
{
while (true)
{
if (_closing)
if (_ctsSource.IsCancellationRequested)
break;
MemoryStream? memoryStream = null;
@@ -460,17 +486,19 @@ namespace CryptoExchange.Net.Sockets
}
catch (Exception wse)
{
// Connection closed unexpectedly
Handle(errorHandlers, wse);
_ = Task.Run(async () => await CloseInternalAsync(true, true).ConfigureAwait(false));
// Connection closed unexpectedly
OnError?.Invoke(wse);
if (_closeTask?.IsCompleted != false)
_closeTask = CloseInternalAsync();
break;
}
if (receiveResult.MessageType == WebSocketMessageType.Close)
{
// Connection closed unexpectedly
log.Write(LogLevel.Debug, $"Socket {Id} received `Close` message");
_ = Task.Run(async () => await CloseInternalAsync(true, true).ConfigureAwait(false));
_log.Write(LogLevel.Debug, $"Socket {Id} received `Close` message");
if (_closeTask?.IsCompleted != false)
_closeTask = CloseInternalAsync();
break;
}
@@ -479,7 +507,7 @@ namespace CryptoExchange.Net.Sockets
// We received data, but it is not complete, write it to a memory stream for reassembling
multiPartMessage = true;
memoryStream ??= new MemoryStream();
log.Write(LogLevel.Trace, $"Socket {Id} received {receiveResult.Count} bytes in partial message");
_log.Write(LogLevel.Trace, $"Socket {Id} received {receiveResult.Count} bytes in partial message");
await memoryStream.WriteAsync(buffer.Array, buffer.Offset, receiveResult.Count).ConfigureAwait(false);
}
else
@@ -487,13 +515,13 @@ namespace CryptoExchange.Net.Sockets
if (!multiPartMessage)
{
// Received a complete message and it's not multi part
log.Write(LogLevel.Trace, $"Socket {Id} received {receiveResult.Count} bytes in single message");
_log.Write(LogLevel.Trace, $"Socket {Id} received {receiveResult.Count} bytes in single message");
HandleMessage(buffer.Array!, buffer.Offset, receiveResult.Count, receiveResult.MessageType);
}
else
{
// Received the end of a multipart message, write to memory stream for reassembling
log.Write(LogLevel.Trace, $"Socket {Id} received {receiveResult.Count} bytes in partial message");
_log.Write(LogLevel.Trace, $"Socket {Id} received {receiveResult.Count} bytes in partial message");
await memoryStream!.WriteAsync(buffer.Array, buffer.Offset, receiveResult.Count).ConfigureAwait(false);
}
break;
@@ -509,7 +537,7 @@ namespace CryptoExchange.Net.Sockets
break;
}
if (receiveResult == null || _closing)
if (receiveResult == null || _ctsSource.IsCancellationRequested)
{
// Error during receiving or cancellation requested, stop.
break;
@@ -521,12 +549,12 @@ namespace CryptoExchange.Net.Sockets
if (receiveResult?.EndOfMessage == true)
{
// Reassemble complete message from memory stream
log.Write(LogLevel.Trace, $"Socket {Id} reassembled message of {memoryStream!.Length} bytes");
_log.Write(LogLevel.Trace, $"Socket {Id} reassembled message of {memoryStream!.Length} bytes");
HandleMessage(memoryStream!.ToArray(), 0, (int)memoryStream.Length, receiveResult.MessageType);
memoryStream.Dispose();
}
else
log.Write(LogLevel.Trace, $"Socket {Id} discarding incomplete message of {memoryStream!.Length} bytes");
_log.Write(LogLevel.Trace, $"Socket {Id} discarding incomplete message of {memoryStream!.Length} bytes");
}
}
}
@@ -535,13 +563,13 @@ namespace CryptoExchange.Net.Sockets
// Because this is running in a separate task and not awaited until the socket gets closed
// any exception here will crash the receive processing, but do so silently unless the socket gets stopped.
// Make sure we at least let the owner know there was an error
Handle(errorHandlers, e);
_log.Write(LogLevel.Warning, $"Socket {Id} Receive loop stopped with exception");
OnError?.Invoke(e);
throw;
}
finally
{
log.Write(LogLevel.Trace, $"Socket {Id} Receive loop finished");
_startedReceive = false;
_log.Write(LogLevel.Debug, $"Socket {Id} Receive loop finished");
}
}
@@ -557,65 +585,103 @@ namespace CryptoExchange.Net.Sockets
string strData;
if (messageType == WebSocketMessageType.Binary)
{
if (DataInterpreterBytes == null)
if (Parameters.DataInterpreterBytes == null)
throw new Exception("Byte interpreter not set while receiving byte data");
try
{
var relevantData = new byte[count];
Array.Copy(data, offset, relevantData, 0, count);
strData = DataInterpreterBytes(relevantData);
strData = Parameters.DataInterpreterBytes(relevantData);
}
catch(Exception e)
{
log.Write(LogLevel.Error, $"Socket {Id} unhandled exception during byte data interpretation: " + e.ToLogString());
_log.Write(LogLevel.Error, $"Socket {Id} unhandled exception during byte data interpretation: " + e.ToLogString());
return;
}
}
else
strData = _encoding.GetString(data, offset, count);
strData = Parameters.Encoding.GetString(data, offset, count);
if (DataInterpreterString != null)
if (Parameters.DataInterpreterString != null)
{
try
{
strData = DataInterpreterString(strData);
strData = Parameters.DataInterpreterString(strData);
}
catch(Exception e)
{
log.Write(LogLevel.Error, $"Socket {Id} unhandled exception during string data interpretation: " + e.ToLogString());
_log.Write(LogLevel.Error, $"Socket {Id} unhandled exception during string data interpretation: " + e.ToLogString());
return;
}
}
try
{
Handle(messageHandlers, strData);
LastActionTime = DateTime.UtcNow;
OnMessage?.Invoke(strData);
}
catch(Exception e)
{
log.Write(LogLevel.Error, $"Socket {Id} unhandled exception during message processing: " + e.ToLogString());
_log.Write(LogLevel.Error, $"Socket {Id} unhandled exception during message processing: " + e.ToLogString());
}
}
/// <summary>
/// Trigger the OnMessage event
/// </summary>
/// <param name="data"></param>
protected void TriggerOnMessage(string data)
{
LastActionTime = DateTime.UtcNow;
OnMessage?.Invoke(data);
}
/// <summary>
/// Trigger the OnError event
/// </summary>
/// <param name="ex"></param>
protected void TriggerOnError(Exception ex) => OnError?.Invoke(ex);
/// <summary>
/// Trigger the OnError event
/// </summary>
protected void TriggerOnOpen() => OnOpen?.Invoke();
/// <summary>
/// Trigger the OnError event
/// </summary>
protected void TriggerOnClose() => OnClose?.Invoke();
/// <summary>
/// Trigger the OnReconnecting event
/// </summary>
protected void TriggerOnReconnecting() => OnReconnecting?.Invoke();
/// <summary>
/// Trigger the OnReconnected event
/// </summary>
protected void TriggerOnReconnected() => OnReconnected?.Invoke();
/// <summary>
/// Checks if there is no data received for a period longer than the specified timeout
/// </summary>
/// <returns></returns>
protected async Task CheckTimeoutAsync()
{
log.Write(LogLevel.Debug, $"Socket {Id} Starting task checking for no data received for {Timeout}");
_log.Write(LogLevel.Debug, $"Socket {Id} Starting task checking for no data received for {Parameters.Timeout}");
LastActionTime = DateTime.UtcNow;
try
{
while (true)
{
if (_closing)
if (_ctsSource.IsCancellationRequested)
return;
if (DateTime.UtcNow - LastActionTime > Timeout)
if (DateTime.UtcNow - LastActionTime > Parameters.Timeout)
{
log.Write(LogLevel.Warning, $"Socket {Id} No data received for {Timeout}, reconnecting socket");
_ = CloseAsync().ConfigureAwait(false);
_log.Write(LogLevel.Warning, $"Socket {Id} No data received for {Parameters.Timeout}, reconnecting socket");
_ = ReconnectAsync().ConfigureAwait(false);
return;
}
try
@@ -634,35 +700,11 @@ namespace CryptoExchange.Net.Sockets
// Because this is running in a separate task and not awaited until the socket gets closed
// any exception here will stop the timeout checking, but do so silently unless the socket get's stopped.
// Make sure we at least let the owner know there was an error
Handle(errorHandlers, e);
OnError?.Invoke(e);
throw;
}
}
/// <summary>
/// Helper to invoke handlers
/// </summary>
/// <param name="handlers"></param>
protected void Handle(List<Action> handlers)
{
LastActionTime = DateTime.UtcNow;
foreach (var handle in new List<Action>(handlers))
handle?.Invoke();
}
/// <summary>
/// Helper to invoke handlers
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="handlers"></param>
/// <param name="data"></param>
protected void Handle<T>(List<Action<T>> handlers, T data)
{
LastActionTime = DateTime.UtcNow;
foreach (var handle in new List<Action<T>>(handlers))
handle?.Invoke(data);
}
/// <summary>
/// Get the next identifier
/// </summary>
@@ -698,6 +740,28 @@ namespace CryptoExchange.Net.Sockets
_lastReceivedMessagesUpdate = checkTime;
}
}
/// <summary>
/// Set proxy on socket
/// </summary>
/// <param name="socket"></param>
/// <param name="proxy"></param>
/// <exception cref="ArgumentException"></exception>
protected virtual void SetProxy(ClientWebSocket socket, ApiProxy proxy)
{
if (!Uri.TryCreate($"{proxy.Host}:{proxy.Port}", UriKind.Absolute, out var uri))
throw new ArgumentException("Proxy settings invalid, {proxy.Host}:{proxy.Port} not a valid URI", nameof(proxy));
socket.Options.Proxy = uri?.Scheme == null
? socket.Options.Proxy = new WebProxy(proxy.Host, proxy.Port)
: socket.Options.Proxy = new WebProxy
{
Address = uri
};
if (proxy.Login != null)
socket.Options.Proxy.Credentials = new NetworkCredential(proxy.Login, proxy.Password);
}
}
/// <summary>
+6 -1
View File
@@ -25,7 +25,12 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public T Data { get; set; }
internal DataEvent(T data, DateTime timestamp)
/// <summary>
/// Ctor
/// </summary>
/// <param name="data"></param>
/// <param name="timestamp"></param>
public DataEvent(T data, DateTime timestamp)
{
Data = data;
Timestamp = timestamp;
@@ -0,0 +1,51 @@
using CryptoExchange.Net.Objects;
using Newtonsoft.Json.Linq;
using System;
using System.Threading;
namespace CryptoExchange.Net.Sockets
{
internal class PendingRequest
{
public Func<JToken, bool> Handler { get; }
public JToken? Result { get; private set; }
public bool Completed { get; private set; }
public AsyncResetEvent Event { get; }
public DateTime RequestTimestamp { get; set; }
public TimeSpan Timeout { get; }
public SocketSubscription? Subscription { get; }
private CancellationTokenSource cts;
public PendingRequest(Func<JToken, bool> handler, TimeSpan timeout, SocketSubscription? subscription)
{
Handler = handler;
Event = new AsyncResetEvent(false, false);
Timeout = timeout;
RequestTimestamp = DateTime.UtcNow;
Subscription = subscription;
cts = new CancellationTokenSource(timeout);
cts.Token.Register(Fail, false);
}
public bool CheckData(JToken data)
{
return Handler(data);
}
public bool Succeed(JToken data)
{
Result = data;
Completed = true;
Event.Set();
return true;
}
public void Fail()
{
Completed = true;
Event.Set();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -43,19 +43,30 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public bool Confirmed { get; set; }
/// <summary>
/// Whether authentication is needed for this subscription
/// </summary>
public bool Authenticated { get; set; }
/// <summary>
/// Whether we're closing this subscription and a socket connection shouldn't be kept open for it
/// </summary>
public bool Closed { get; set; }
/// <summary>
/// Cancellation token registration, should be disposed when subscription is closed. Used for closing the subscription with
/// a provided cancelation token
/// </summary>
public CancellationTokenRegistration? CancellationTokenRegistration { get; set; }
private SocketSubscription(int id, object? request, string? identifier, bool userSubscription, Action<MessageEvent> dataHandler)
private SocketSubscription(int id, object? request, string? identifier, bool userSubscription, bool authenticated, Action<MessageEvent> dataHandler)
{
Id = id;
UserSubscription = userSubscription;
MessageHandler = dataHandler;
Request = request;
Identifier = identifier;
Authenticated = authenticated;
}
/// <summary>
@@ -64,12 +75,13 @@ namespace CryptoExchange.Net.Sockets
/// <param name="id"></param>
/// <param name="request"></param>
/// <param name="userSubscription"></param>
/// <param name="authenticated"></param>
/// <param name="dataHandler"></param>
/// <returns></returns>
public static SocketSubscription CreateForRequest(int id, object request, bool userSubscription,
Action<MessageEvent> dataHandler)
bool authenticated, Action<MessageEvent> dataHandler)
{
return new SocketSubscription(id, request, null, userSubscription, dataHandler);
return new SocketSubscription(id, request, null, userSubscription, authenticated, dataHandler);
}
/// <summary>
@@ -78,12 +90,13 @@ namespace CryptoExchange.Net.Sockets
/// <param name="id"></param>
/// <param name="identifier"></param>
/// <param name="userSubscription"></param>
/// <param name="authenticated"></param>
/// <param name="dataHandler"></param>
/// <returns></returns>
public static SocketSubscription CreateForIdentifier(int id, string identifier, bool userSubscription,
Action<MessageEvent> dataHandler)
bool authenticated, Action<MessageEvent> dataHandler)
{
return new SocketSubscription(id, null, identifier, userSubscription, dataHandler);
return new SocketSubscription(id, null, identifier, userSubscription, authenticated, dataHandler);
}
/// <summary>
@@ -22,8 +22,7 @@ namespace CryptoExchange.Net.Sockets
}
/// <summary>
/// Event when the connection is closed. This event happens when reconnecting/resubscribing has failed too often based on the <see cref="BaseSocketClientOptions.MaxReconnectTries"/> and <see cref="BaseSocketClientOptions.MaxResubscribeTries"/> options,
/// or <see cref="BaseSocketClientOptions.AutoReconnect"/> is false. The socket will not be reconnected
/// Event when the connection is closed and will not be reconnected
/// </summary>
public event Action ConnectionClosed
{
@@ -72,7 +71,7 @@ namespace CryptoExchange.Net.Sockets
/// <summary>
/// The id of the socket
/// </summary>
public int SocketId => connection.Socket.Id;
public int SocketId => connection.SocketId;
/// <summary>
/// The id of the subscription
@@ -103,9 +102,9 @@ namespace CryptoExchange.Net.Sockets
/// Close the socket to cause a reconnect
/// </summary>
/// <returns></returns>
internal Task ReconnectAsync()
public Task ReconnectAsync()
{
return connection.Socket.CloseAsync();
return connection.TriggerReconnectAsync();
}
/// <summary>
@@ -0,0 +1,80 @@
using CryptoExchange.Net.Objects;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Sockets
{
/// <summary>
/// Parameters for a websocket
/// </summary>
public class WebSocketParameters
{
/// <summary>
/// The uri to connect to
/// </summary>
public Uri Uri { get; set; }
/// <summary>
/// Headers to send in the connection handshake
/// </summary>
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>();
/// <summary>
/// Cookies to send in the connection handshake
/// </summary>
public IDictionary<string, string> Cookies { get; set; } = new Dictionary<string, string>();
/// <summary>
/// The time to wait between reconnect attempts
/// </summary>
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Proxy for the connection
/// </summary>
public ApiProxy? Proxy { get; set; }
/// <summary>
/// Whether the socket should automatically reconnect when connection is lost
/// </summary>
public bool AutoReconnect { get; set; }
/// <summary>
/// The maximum time of no data received before considering the connection lost and closting/reconnecting the socket
/// </summary>
public TimeSpan? Timeout { get; set; }
/// <summary>
/// Interval at which to send ping frames
/// </summary>
public TimeSpan? KeepAliveInterval { get; set; }
/// <summary>
/// The max amount of messages to send per second
/// </summary>
public int? RatelimitPerSecond { get; set; }
/// <summary>
/// Origin header value to send in the connection handshake
/// </summary>
public string? Origin { get; set; }
/// <summary>
/// Delegate used for processing byte data received from socket connections before it is processed by handlers
/// </summary>
public Func<byte[], string>? DataInterpreterBytes { get; set; }
/// <summary>
/// Delegate used for processing string data received from socket connections before it is processed by handlers
/// </summary>
public Func<string, string>? DataInterpreterString { get; set; }
/// <summary>
/// Encoding for sending/receiving data
/// </summary>
public Encoding Encoding { get; set; } = Encoding.UTF8;
/// <summary>
/// ctor
/// </summary>
/// <param name="uri">Uri</param>
/// <param name="autoReconnect">Auto reconnect</param>
public WebSocketParameters(Uri uri, bool autoReconnect)
{
Uri = uri;
AutoReconnect = autoReconnect;
}
}
}
+4 -11
View File
@@ -1,24 +1,17 @@
using System.Collections.Generic;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Logging;
namespace CryptoExchange.Net.Sockets
{
/// <summary>
/// Default weboscket factory implementation
/// Default websocket factory implementation
/// </summary>
public class WebsocketFactory : IWebsocketFactory
{
/// <inheritdoc />
public IWebsocket CreateWebsocket(Log log, string url)
public IWebsocket CreateWebsocket(Log log, WebSocketParameters parameters)
{
return new CryptoExchangeWebSocketClient(log, url);
}
/// <inheritdoc />
public IWebsocket CreateWebsocket(Log log, string url, IDictionary<string, string> cookies, IDictionary<string, string> headers)
{
return new CryptoExchangeWebSocketClient(log, url, cookies, headers);
return new CryptoExchangeWebSocketClient(log, parameters);
}
}
}
+93 -3
View File
@@ -8,16 +8,106 @@ CryptoExchange.Net is a base package which can be used to easily implement crypt
## Discord
A Discord server is available [here](https://discord.gg/MSpeEtSY8t). Feel free to join for discussion and/or questions around the CryptoExchange.Net and implementation libraries.
## Donate / Sponsor
I develop and maintain this package on my own for free in my spare time. Donations are greatly appreciated. If you prefer to donate any other currency please contact me.
## Support the project
I develop and maintain this package on my own for free in my spare time, any support is greatly appreciated.
### Referral link
Use one of the following following referral links to signup to a new exchange to pay a small percentage of the trading fees you pay to support the project instead of paying them straight to the exchange. This doesn't cost you a thing!
[Binance](https://accounts.binance.com/en/register?ref=10153680)
[Bitfinex](https://www.bitfinex.com/sign-up?refcode=kCCe-CNBO)
[Bittrex](https://bittrex.com/discover/join?referralCode=TST-DJM-CSX)
[Bybit](https://partner.bybit.com/b/jkorf)
[CoinEx](https://www.coinex.com/register?refer_code=hd6gn)
[FTX](https://ftx.com/referrals#a=31620192)
[Huobi](https://www.huobi.com/en-us/v/register/double-invite/?inviter_id=11343840&invite_code=fxp93)
[Kucoin](https://www.kucoin.com/ucenter/signup?rcode=RguMux)
### Donate
Make a one time donation in a crypto currency of your choice. If you prefer to donate a currency not listed here please contact me.
**Btc**: 12KwZk3r2Y3JZ2uMULcjqqBvXmpDwjhhQS
**Eth**: 0x069176ca1a4b1d6e0b7901a6bc0dbf3bb0bf5cc2
**Nano**: xrb_1ocs3hbp561ef76eoctjwg85w5ugr8wgimkj8mfhoyqbx4s1pbc74zggw7gs
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf)
### Sponsor
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf).
## Release notes
* Version 5.4.0 - 14 Feb 2023
* Added unsubscribing when receiving subscribe answer after the request timeout has passed
* Fixed socket options copying
* Made TimeSync implementation optional
* Cleaned up ApiCredentials and added better support for extending ApiCredentials
* Version 5.3.1 - 08 Dec 2022
* Added default request parameter ordering before applying authentication
* Fixed possible issue where a socket would reconnect when it should close if it was already in reconnecting
* Version 5.3.0 - 14 Nov 2022
* Reworked client architecture, shifting funcationality to the ApiClient
* Fixed ArrayConverter exponent parsing
* Fixed ArrayConverter not checking null
* Added optional delay setting after establishing socket connection
* Added callback for revitalizing a socket request when reconnecting
* Fixed proxy setting websocket
* Version 5.2.4 - 31 Jul 2022
* Added handling of PlatformNotSupportedException when trying to use websocket from WebAssembly
* Changed DataEvent to have a public constructor for testing purposes
* Fixed EnumConverter serializing values without proper quotes
* Fixed websocket connection reconnecting too quickly when resubscribing/reauthenticating fails
* Version 5.2.3 - 19 Jul 2022
* Fixed socket getting disconnected when `no data` timeout is reached instead of being reconnected
* Version 5.2.2 - 17 Jul 2022
* Added support for retrieving a new url when socket connection is lost and reconnection will happen
* Version 5.2.1 - 16 Jul 2022
* Fixed socket reconnect issue
* Fixed `message not handled` messages after unsubscribing
* Fixed error returning for non-json error responses
* Version 5.2.0 - 10 Jul 2022
* Refactored websocket code, removed some clutter and simplified
* Added ReconnectAsync and GetSubscriptionsState methods on socket clients
* Version 5.1.12 - 12 Jun 2022
* Changed time sync so requests no longer wait for it to complete unless it's the first time
* Made log client options changable after client creation
* Fixed proxy setting not used when reconnecting socket
* Changed MaxSocketConnections to a client options
* Updated socket reconnection logic
* Version 5.1.12 - 12 Jun 2022
* Changed time sync so requests no longer wait for it to complete unless it's the first time
* Made log client options changable after client creation
* Fixed proxy setting not used when reconnecting socket
* Updated socket reconnection logic
* Version 5.1.11 - 24 May 2022
* Added KeepAliveInterval setting
* Fixed port not being copied when setting parameters on request
* Fixed inconsistent PackageReference casing in csproj
* Version 5.1.10 - 22 May 2022
* Fixed order book reconnecting while Diposed
* Fixed exception when disposing socket client while reconnecting
* Added additional null/default checking in DateTimeConverter
* Changed ConnectionLost subscription event to run in seperate task to prevent exception/longer operations from intervering with reconnecting
* Version 5.1.9 - 08 May 2022
* Added latency to the timesync calculation
* Small fix for exception in socket close handling
* Version 5.1.8 - 01 May 2022
* Cleanup socket code, fixed an issue which could cause connections to never reconnect when connection was lost
* Added support for sending requests which expect an empty response
* Fixed issue with the DateTimeConverter date interpretation
* Version 5.1.7 - 14 Apr 2022
* Moved some Rest parameters from BaseRestClient to RestApiClient to allow different implementations for sub clients
* Version 5.1.6 - 10 Mar 2022
* Updated EnumConverter to properly handle emtpy/null and default values
+1 -1
View File
@@ -105,7 +105,7 @@ All updates are wrapped in a `DataEvent<>` object, which contain a `Timestamp`,
*[WARNING] Do not use `using` statements in combination with constructing a `SocketClient`. Doing so will dispose the `SocketClient` instance when the subscription is done, which will result in the connection getting closed. Instead assign the socket client to a variable outside of the method scope.*
### Processing subscribe responses
Subscribing to a stream will return a `CallResult<UpdateSubscription>` object. This should be checked for success the same was as the [rest client](#processing-request-responses). The `UpdateSubscription` object can be used to listen for connection events of the socket connection.
Subscribing to a stream will return a `CallResult<UpdateSubscription>` object. This should be checked for success the same way as the [rest client](#processing-request-responses). The `UpdateSubscription` object can be used to listen for connection events of the socket connection.
```csharp
var subscriptionResult = await kucoinSocketClient.SpotStreams.SubscribeToAllTickerUpdatesAsync(DataHandler);
+4 -1
View File
@@ -61,4 +61,7 @@ var client = new BinanceClient(new BinanceClientOptions
BaseAddress = BinanceApiAddresses.TestNet.UsdFuturesRestClientAddress
}
});
```
```
### How are timezones handled / Timestamps are off by xx
Exchange API's treat all timestamps as UTC, both incoming and outgoing. The client libraries do no conversion so be sure to use UTC as well.
+18 -3
View File
@@ -42,11 +42,26 @@ These might not be compatible with other libraries, make sure to check the Crypt
## Discord
A Discord server is available [here](https://discord.gg/MSpeEtSY8t). Feel free to join for discussion and/or questions around the CryptoExchange.Net and implementation libraries.
## Donate / Sponsor
I develop and maintain this package on my own for free in my spare time. Donations are greatly appreciated. If you prefer to donate any other currency please contact me.
## Support the project
I develop and maintain this package on my own for free in my spare time, any support is greatly appreciated.
### Referral link
Use one of the following following referral links to signup to a new exchange to pay a small percentage of the trading fees you pay to support the project instead of paying them straight to the exchange. This doesn't cost you a thing!
[Binance](https://accounts.binance.com/en/register?ref=10153680)
[Bitfinex](https://www.bitfinex.com/sign-up?refcode=kCCe-CNBO)
[Bittrex](https://bittrex.com/discover/join?referralCode=TST-DJM-CSX)
[Bybit](https://partner.bybit.com/b/jkorf)
[CoinEx](https://www.coinex.com/register?refer_code=hd6gn)
[FTX](https://ftx.com/referrals#a=31620192)
[Huobi](https://www.huobi.com/en-us/v/register/double-invite/?inviter_id=11343840&invite_code=fxp93)
[Kucoin](https://www.kucoin.com/ucenter/signup?rcode=RguMux)
### Donate
Make a one time donation in a crypto currency of your choice. If you prefer to donate a currency not listed here please contact me.
**Btc**: 12KwZk3r2Y3JZ2uMULcjqqBvXmpDwjhhQS
**Eth**: 0x069176ca1a4b1d6e0b7901a6bc0dbf3bb0bf5cc2
**Nano**: xrb_1ocs3hbp561ef76eoctjwg85w5ugr8wgimkj8mfhoyqbx4s1pbc74zggw7gs
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf)
### Sponsor
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf).