1
0
mirror of https://github.com/JKorf/CryptoExchange.Net synced 2025-07-08 15:16:15 +00:00

Compare commits

...

5 Commits

Author SHA1 Message Date
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
21 changed files with 1970 additions and 1790 deletions

View File

@ -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);

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

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
@ -148,7 +148,7 @@ namespace CryptoExchange.Net.UnitTests
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 },

View File

@ -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, null));
var connectResult = client.SubClient.ConnectSocketSub(new SocketConnection(new Log(""), client.SubClient, socket, null));
//assert
Assert.IsTrue(connectResult.Success == canConnect);
@ -52,12 +53,18 @@ 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, null);
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, false, (messageEvent) =>
@ -65,7 +72,7 @@ namespace CryptoExchange.Net.UnitTests
result = messageEvent.JsonData;
rstEvent.Set();
}));
client.ConnectSocketSub(sub);
client.SubClient.ConnectSocketSub(sub);
// act
socket.InvokeMessage("{\"property\": 123}");
@ -80,12 +87,19 @@ 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, null);
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, false, (messageEvent) =>
@ -93,7 +107,7 @@ namespace CryptoExchange.Net.UnitTests
original = messageEvent.OriginalData;
rstEvent.Set();
}));
client.ConnectSocketSub(sub);
client.SubClient.ConnectSocketSub(sub);
// act
socket.InvokeMessage("{\"property\": 123}");
@ -107,11 +121,18 @@ namespace CryptoExchange.Net.UnitTests
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, null);
client.ConnectSocketSub(sub);
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);
@ -127,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, null);
var sub2 = new SocketConnection(client, null, socket2, null);
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();
@ -149,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, null);
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);

View File

@ -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() => throw new NotImplementedException();
public override TimeSyncInfo GetTimeSyncInfo() => throw new NotImplementedException();
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException();
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
}
public class TestAuthProvider : AuthenticationProvider

View File

@ -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,7 +29,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
{
Api1 = new TestRestApi1Client(exchangeOptions);
Api2 = new TestRestApi2Client(exchangeOptions);
RequestFactory = new Mock<IRequestFactory>().Object;
}
public void SetResponse(string responseData, out IRequest requestObj)
@ -50,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) =>
{
@ -58,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;
}
@ -71,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);
}
@ -94,27 +108,33 @@ 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 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)
@ -143,9 +163,19 @@ 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 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()
@ -186,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"]);
}
}
}

View File

@ -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<WebSocketParameters>())).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<WebSocketParameters>())).Returns(new TestSocket());
return (TestSocket)CreateSocket("https://localhost: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);
}
}

View File

@ -1,8 +1,16 @@
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.Logging;
using CryptoExchange.Net.Objects;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net
{
@ -14,7 +22,16 @@ namespace CryptoExchange.Net
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)
@ -70,19 +87,39 @@ namespace CryptoExchange.Net
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;
}
@ -101,10 +138,216 @@ namespace CryptoExchange.Net
_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();

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,35 +24,18 @@ 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);
@ -72,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>
public void SetApiCredentials(ApiCredentials credentials)
{
foreach (var apiClient in ApiClients)
apiClient.SetApiCredentials(credentials);
}
/// <summary>
/// Register an API client
/// </summary>
@ -83,206 +70,6 @@ namespace CryptoExchange.Net
return apiClient;
}
/// <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 (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;
}
}
/// <summary>
/// Handle a change in the client options log config
/// </summary>

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,475 +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();
/// <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 returns if it was successful
/// </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="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(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)
{
var request = await PrepareRequestAsync(apiClient, 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>(apiClient, request.Data, deserializer, cancellationToken, true).ConfigureAwait(false);
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="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 request = await PrepareRequestAsync(apiClient, uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
if (!request)
return new WebCallResult<T>(request.Error!);
return await GetResponseAsync<T>(apiClient, request.Data, deserializer, cancellationToken, false).ConfigureAwait(false);
}
/// <summary>
/// Prepares a request to be sent to the server
/// </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="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(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)
{
var requestId = NextId();
if (signed)
{
var syncTask = apiClient.SyncTimeAsync();
var timeSyncInfo = apiClient.GetTimeSyncInfo();
if (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 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 CallResult<IRequest>(limitResult.Error!);
}
}
if (signed && apiClient.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 ?? apiClient.ParameterPositions[method];
var request = ConstructRequest(apiClient, uri, method, parameters, signed, paramsPosition, arraySerialization ?? apiClient.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 new CallResult<IRequest>(request);
}
/// <summary>
/// Executes the request and returns the result deserialized into the type parameter class
/// </summary>
/// <param name="apiClient">The client making the request</param>
/// <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>(
BaseApiClient apiClient,
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 (apiClient.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, 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
{
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, ClientOptions.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, ClientOptions.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, ClientOptions.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, 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) : 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>
/// 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 = apiClient.requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
if (bodyParameters.Any())
WriteParamBody(apiClient, request, bodyParameters, contentType);
else
request.SetContent(apiClient.requestBodyEmptyContent, contentType);
}
return request;
}
/// <summary>
/// Writes the parameters of the request to the request object body
/// </summary>
/// <param name="apiClient">The client making the request</param>
/// <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(BaseApiClient apiClient, IRequest request, SortedDictionary<string, object> parameters, string contentType)
{
if (apiClient.requestBodyFormat == RequestBodyFormat.Json)
{
// Write the parameters as json in the body
var stringData = JsonConvert.SerializeObject(parameters);
request.SetContent(stringData, contentType);
}
else if (apiClient.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());
}
}
}

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.Text;
using System.Threading;
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.Linq;
namespace CryptoExchange.Net
{
@ -21,95 +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> 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 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 (!socketConnections.Any())
return 0;
return socketConnections.Sum(s => s.Value.IncomingKbps);
}
}
public int CurrentConnections => ApiClients.OfType<SocketApiClient>().Sum(c => c.CurrentConnections);
/// <inheritdoc />
public int CurrentConnections => socketConnections.Count;
public int CurrentSubscriptions => ApiClients.OfType<SocketApiClient>().Sum(s => s.CurrentSubscriptions);
/// <inheritdoc />
public int CurrentSubscriptions
{
get
{
if (!socketConnections.Any())
return 0;
return socketConnections.Sum(s => s.Value.SubscriptionCount);
}
}
/// <summary>
/// Client options
/// </summary>
public new BaseSocketClientOptions ClientOptions { get; }
public double IncomingKbps => ApiClients.OfType<SocketApiClient>().Sum(s => s.IncomingKbps);
#endregion
/// <summary>
@ -117,588 +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)
{
ClientOptions = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public void SetApiCredentials(ApiCredentials credentials)
{
foreach (var apiClient in ApiClients)
apiClient.SetApiCredentials(credentials);
}
/// <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
{
while (true)
{
// Get a new or existing socket connection
var socketResult = await GetSocketConnection(apiClient, 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 (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!);
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, 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
{
var socketResult = await GetSocketConnection(apiClient, url, authenticated).ConfigureAwait(false);
if (!socketResult)
return socketResult.As<T>(default);
socketConnection = socketResult.Data;
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
{
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, 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);
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, ClientOptions.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, ClientOptions.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="apiClient"></param>
/// <param name="address"></param>
/// <param name="authentication"></param>
/// <returns></returns>
protected virtual Task<CallResult<string?>> GetConnectionUrlAsync(SocketApiClient apiClient, 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="apiClient"></param>
/// <param name="connection"></param>
/// <returns></returns>
public virtual Task<Uri?> GetReconnectUriAsync(SocketApiClient apiClient, SocketConnection connection)
{
return Task.FromResult<Uri?>(connection.ConnectionUri);
}
/// <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 async Task<CallResult<SocketConnection>> GetSocketConnection(SocketApiClient apiClient, 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() == 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 || (socketConnections.Count >= ClientOptions.MaxSocketConnections && socketConnections.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 new CallResult<SocketConnection>(result);
}
}
var connectionAddress = await GetConnectionUrlAsync(apiClient, 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(this, apiClient, 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), ClientOptions.AutoReconnect)
{
DataInterpreterBytes = dataInterpreterBytes,
DataInterpreterString = dataInterpreterString,
KeepAliveInterval = KeepAliveInterval,
ReconnectInterval = ClientOptions.ReconnectInterval,
RatelimitPerSecond = RateLimitPerSocketPerSecond,
Proxy = ClientOptions.Proxy,
Timeout = ClientOptions.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>
@ -708,23 +47,12 @@ namespace CryptoExchange.Net
/// <returns></returns>
public virtual async Task UnsubscribeAsync(int subscriptionId)
{
SocketSubscription? subscription = null;
SocketConnection? connection = null;
foreach(var socket in socketConnections.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, $"Socket {connection.SocketId} Unsubscribing subscription " + subscriptionId);
await connection.CloseAsync(subscription).ConfigureAwait(false);
}
/// <summary>
@ -747,14 +75,10 @@ namespace CryptoExchange.Net
/// <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());
}
var tasks = new List<Task>();
foreach (var client in ApiClients.OfType<SocketApiClient>())
tasks.Add(client.UnsubscribeAllAsync());
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
@ -764,14 +88,12 @@ namespace CryptoExchange.Net
/// <returns></returns>
public virtual async Task ReconnectAsync()
{
log.Write(LogLevel.Information, $"Reconnecting all {socketConnections.Count} connections");
log.Write(LogLevel.Information, $"Reconnecting all {CurrentConnections} connections");
var tasks = new List<Task>();
foreach (var client in ApiClients.OfType<SocketApiClient>())
{
var socketList = socketConnections.Values;
foreach (var sub in socketList)
tasks.Add(sub.TriggerReconnectAsync());
tasks.Add(client.ReconnectAsync());
}
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
@ -780,32 +102,10 @@ namespace CryptoExchange.Net
/// </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();
var result = new StringBuilder();
foreach(var client in ApiClients.OfType<SocketApiClient>())
result.AppendLine(client.GetSubscriptionsState());
return result.ToString();
}
}
}

View File

@ -1,11 +1,19 @@
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
{
@ -14,6 +22,16 @@ namespace CryptoExchange.Net
/// </summary>
public abstract class RestApiClient: BaseApiClient
{
/// <summary>
/// The factory for creating requests. Used for unit testing
/// </summary>
public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
/// <summary>
/// Request headers to be sent with each request
/// </summary>
protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
/// <summary>
/// Get time sync info for an API client
/// </summary>
@ -41,17 +59,455 @@ 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)
{
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);
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
{
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!);
return await GetResponseAsync<T>(request.Data, deserializer, cancellationToken, false).ConfigureAwait(false);
}
/// <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.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, 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>
/// 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)
{
AuthenticationProvider.AuthenticateRequest(
this,
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());
}
/// <summary>

View File

@ -1,4 +1,17 @@
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
{
@ -7,13 +20,792 @@ namespace CryptoExchange.Net
/// </summary>
public abstract class SocketApiClient : BaseApiClient
{
#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> 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, 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, 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();
}
}
}

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.2.4</PackageVersion>
<AssemblyVersion>5.2.4</AssemblyVersion>
<FileVersion>5.2.4</FileVersion>
<PackageVersion>5.3.0</PackageVersion>
<AssemblyVersion>5.3.0</AssemblyVersion>
<FileVersion>5.3.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.2.4 - 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</PackageReleaseNotes>
<PackageReleaseNotes>5.3.0 - 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</PackageReleaseNotes>
<Nullable>enable</Nullable>
<LangVersion>9.0</LangVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>

View File

@ -10,20 +10,15 @@ 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>

View File

@ -14,7 +14,7 @@ namespace CryptoExchange.Net.Interfaces
/// <summary>
/// The options provided for this client
/// </summary>
BaseSocketClientOptions ClientOptions { get; }
ClientOptions 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.

View File

@ -10,9 +10,9 @@ 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;
@ -44,195 +44,55 @@ namespace CryptoExchange.Net.Objects
}
}
/// <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)
{
}
/// <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>
/// 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>
/// 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;
MaxConcurrentResubscriptionsPerSocket = baseOptions.MaxConcurrentResubscriptionsPerSocket;
SocketResponseTimeout = baseOptions.SocketResponseTimeout;
SocketNoDataTimeout = baseOptions.SocketNoDataTimeout;
SocketSubscriptionsCombineTarget = baseOptions.SocketSubscriptionsCombineTarget;
MaxSocketConnections = baseOptions.MaxSocketConnections;
}
/// <inheritdoc />
public override string ToString()
{
return $"{base.ToString()}, AutoReconnect: {AutoReconnect}, ReconnectInterval: {ReconnectInterval}, MaxConcurrentResubscriptionsPerSocket: {MaxConcurrentResubscriptionsPerSocket}, SocketResponseTimeout: {SocketResponseTimeout:c}, SocketNoDataTimeout: {SocketNoDataTimeout}, SocketSubscriptionsCombineTarget: {SocketSubscriptionsCombineTarget}, MaxSocketConnections: {MaxSocketConnections}";
return $"LogLevel: {LogLevel}, Writers: {LogWriters.Count}, Proxy: {(Proxy == null ? "-" : Proxy.Host)}";
}
}
@ -241,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>
@ -278,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}";
}
}
@ -292,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>
@ -334,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;
@ -343,14 +221,103 @@ 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 = 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 = baseOptions.AutoReconnect;
ReconnectInterval = baseOptions.ReconnectInterval;
MaxConcurrentResubscriptionsPerSocket = baseOptions.MaxConcurrentResubscriptionsPerSocket;
SocketResponseTimeout = baseOptions.SocketResponseTimeout;
SocketNoDataTimeout = baseOptions.SocketNoDataTimeout;
SocketSubscriptionsCombineTarget = baseOptions.SocketSubscriptionsCombineTarget;
MaxSocketConnections = baseOptions.MaxSocketConnections;
}
/// <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.

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;
}
}
@ -581,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()
@ -752,7 +754,9 @@ namespace CryptoExchange.Net.OrderBook
_ = _subscription!.ReconnectAsync();
}
else
{
await ResyncAsync().ConfigureAwait(false);
}
});
}

View File

@ -165,7 +165,7 @@ namespace CryptoExchange.Net.Sockets
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(Parameters.Proxy);
SetProxy(socket, Parameters.Proxy);
}
catch (PlatformNotSupportedException)
{
@ -739,22 +739,23 @@ namespace CryptoExchange.Net.Sockets
/// <summary>
/// Set proxy on socket
/// </summary>
/// <param name="socket"></param>
/// <param name="proxy"></param>
/// <exception cref="ArgumentException"></exception>
protected virtual void SetProxy(ApiProxy proxy)
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
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);
socket.Options.Proxy.Credentials = new NetworkCredential(proxy.Login, proxy.Password);
}
}

View File

@ -53,8 +53,8 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public int SubscriptionCount
{
get { lock (subscriptionLock)
return subscriptions.Count(h => h.UserSubscription); }
get { lock (_subscriptionLock)
return _subscriptions.Count(h => h.UserSubscription); }
}
/// <summary>
@ -64,8 +64,8 @@ namespace CryptoExchange.Net.Sockets
{
get
{
lock (subscriptionLock)
return subscriptions.Where(h => h.UserSubscription).ToArray();
lock (_subscriptionLock)
return _subscriptions.Where(h => h.UserSubscription).ToArray();
}
}
@ -114,14 +114,14 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public bool PausedActivity
{
get => pausedActivity;
get => _pausedActivity;
set
{
if (pausedActivity != value)
if (_pausedActivity != value)
{
pausedActivity = value;
log.Write(LogLevel.Information, $"Socket {SocketId} Paused activity: " + value);
if(pausedActivity) _ = Task.Run(() => ActivityPaused?.Invoke());
_pausedActivity = value;
_log.Write(LogLevel.Information, $"Socket {SocketId} Paused activity: " + value);
if(_pausedActivity) _ = Task.Run(() => ActivityPaused?.Invoke());
else _ = Task.Run(() => ActivityUnpaused?.Invoke());
}
}
@ -140,18 +140,17 @@ namespace CryptoExchange.Net.Sockets
var oldStatus = _status;
_status = value;
log.Write(LogLevel.Debug, $"Socket {SocketId} status changed from {oldStatus} to {_status}");
_log.Write(LogLevel.Debug, $"Socket {SocketId} status changed from {oldStatus} to {_status}");
}
}
private bool pausedActivity;
private readonly List<SocketSubscription> subscriptions;
private readonly object subscriptionLock = new();
private bool _pausedActivity;
private readonly List<SocketSubscription> _subscriptions;
private readonly object _subscriptionLock = new();
private readonly Log log;
private readonly BaseSocketClient socketClient;
private readonly Log _log;
private readonly List<PendingRequest> pendingRequests;
private readonly List<PendingRequest> _pendingRequests;
private SocketStatus _status;
@ -163,19 +162,18 @@ namespace CryptoExchange.Net.Sockets
/// <summary>
/// New socket connection
/// </summary>
/// <param name="client">The socket client</param>
/// <param name="log">The logger</param>
/// <param name="apiClient">The api client</param>
/// <param name="socket">The socket</param>
/// <param name="tag"></param>
public SocketConnection(BaseSocketClient client, SocketApiClient apiClient, IWebsocket socket, string tag)
public SocketConnection(Log log, SocketApiClient apiClient, IWebsocket socket, string tag)
{
log = client.log;
socketClient = client;
this._log = log;
ApiClient = apiClient;
Tag = tag;
pendingRequests = new List<PendingRequest>();
subscriptions = new List<SocketSubscription>();
_pendingRequests = new List<PendingRequest>();
_subscriptions = new List<SocketSubscription>();
_socket = socket;
_socket.OnMessage += HandleMessage;
@ -203,9 +201,9 @@ namespace CryptoExchange.Net.Sockets
{
Status = SocketStatus.Closed;
Authenticated = false;
lock(subscriptionLock)
lock(_subscriptionLock)
{
foreach (var sub in subscriptions)
foreach (var sub in _subscriptions)
sub.Confirmed = false;
}
Task.Run(() => ConnectionClosed?.Invoke());
@ -219,9 +217,9 @@ namespace CryptoExchange.Net.Sockets
Status = SocketStatus.Reconnecting;
DisconnectTime = DateTime.UtcNow;
Authenticated = false;
lock (subscriptionLock)
lock (_subscriptionLock)
{
foreach (var sub in subscriptions)
foreach (var sub in _subscriptions)
sub.Confirmed = false;
}
@ -234,7 +232,7 @@ namespace CryptoExchange.Net.Sockets
/// <returns></returns>
protected virtual async Task<Uri?> GetReconnectionUrlAsync()
{
return await socketClient.GetReconnectUriAsync(ApiClient, this).ConfigureAwait(false);
return await ApiClient.GetReconnectUriAsync(this).ConfigureAwait(false);
}
/// <summary>
@ -243,18 +241,21 @@ namespace CryptoExchange.Net.Sockets
protected virtual async void HandleReconnected()
{
Status = SocketStatus.Resubscribing;
lock (pendingRequests)
lock (_pendingRequests)
{
foreach (var pendingRequest in pendingRequests.ToList())
foreach (var pendingRequest in _pendingRequests.ToList())
{
pendingRequest.Fail();
pendingRequests.Remove(pendingRequest);
_pendingRequests.Remove(pendingRequest);
}
}
var reconnectSuccessful = await ProcessReconnectAsync().ConfigureAwait(false);
if (!reconnectSuccessful)
{
_log.Write(LogLevel.Warning, "Failed reconnect processing, reconnecting again");
await _socket.ReconnectAsync().ConfigureAwait(false);
}
else
{
Status = SocketStatus.Connected;
@ -273,9 +274,9 @@ namespace CryptoExchange.Net.Sockets
protected virtual void HandleError(Exception e)
{
if (e is WebSocketException wse)
log.Write(LogLevel.Warning, $"Socket {SocketId} error: Websocket error code {wse.WebSocketErrorCode}, details: " + e.ToLogString());
_log.Write(LogLevel.Warning, $"Socket {SocketId} error: Websocket error code {wse.WebSocketErrorCode}, details: " + e.ToLogString());
else
log.Write(LogLevel.Warning, $"Socket {SocketId} error: " + e.ToLogString());
_log.Write(LogLevel.Warning, $"Socket {SocketId} error: " + e.ToLogString());
}
/// <summary>
@ -285,14 +286,14 @@ namespace CryptoExchange.Net.Sockets
protected virtual void HandleMessage(string data)
{
var timestamp = DateTime.UtcNow;
log.Write(LogLevel.Trace, $"Socket {SocketId} received data: " + data);
_log.Write(LogLevel.Trace, $"Socket {SocketId} received data: " + data);
if (string.IsNullOrEmpty(data)) return;
var tokenData = data.ToJToken(log);
var tokenData = data.ToJToken(_log);
if (tokenData == null)
{
data = $"\"{data}\"";
tokenData = data.ToJToken(log);
tokenData = data.ToJToken(_log);
if (tokenData == null)
return;
}
@ -301,10 +302,10 @@ namespace CryptoExchange.Net.Sockets
// Remove any timed out requests
PendingRequest[] requests;
lock (pendingRequests)
lock (_pendingRequests)
{
pendingRequests.RemoveAll(r => r.Completed);
requests = pendingRequests.ToArray();
_pendingRequests.RemoveAll(r => r.Completed);
requests = _pendingRequests.ToArray();
}
// Check if this message is an answer on any pending requests
@ -312,10 +313,10 @@ namespace CryptoExchange.Net.Sockets
{
if (pendingRequest.CheckData(tokenData))
{
lock (pendingRequests)
pendingRequests.Remove(pendingRequest);
lock (_pendingRequests)
_pendingRequests.Remove(pendingRequest);
if (!socketClient.ContinueOnQueryResponse)
if (!ApiClient.ContinueOnQueryResponse)
return;
handledResponse = true;
@ -324,21 +325,23 @@ namespace CryptoExchange.Net.Sockets
}
// Message was not a request response, check data handlers
var messageEvent = new MessageEvent(this, tokenData, socketClient.ClientOptions.OutputOriginalData ? data : null, timestamp);
var messageEvent = new MessageEvent(this, tokenData, ApiClient.Options.OutputOriginalData ? data : null, timestamp);
var (handled, userProcessTime, subscription) = HandleData(messageEvent);
if (!handled && !handledResponse)
{
if (!socketClient.UnhandledMessageExpected)
log.Write(LogLevel.Warning, $"Socket {SocketId} Message not handled: " + tokenData);
if (!ApiClient.UnhandledMessageExpected)
_log.Write(LogLevel.Warning, $"Socket {SocketId} Message not handled: " + tokenData);
UnhandledMessage?.Invoke(tokenData);
}
var total = DateTime.UtcNow - timestamp;
if (userProcessTime.TotalMilliseconds > 500)
log.Write(LogLevel.Debug, $"Socket {SocketId}{(subscription == null ? "" : " subscription " + subscription!.Id)} message processing slow ({(int)total.TotalMilliseconds}ms, {(int)userProcessTime.TotalMilliseconds}ms user code), consider offloading data handling to another thread. " +
{
_log.Write(LogLevel.Debug, $"Socket {SocketId}{(subscription == null ? "" : " subscription " + subscription!.Id)} message processing slow ({(int)total.TotalMilliseconds}ms, {(int)userProcessTime.TotalMilliseconds}ms user code), consider offloading data handling to another thread. " +
"Data from this socket may arrive late or not at all if message processing is continuously slow.");
}
log.Write(LogLevel.Trace, $"Socket {SocketId}{(subscription == null ? "" : " subscription " + subscription!.Id)} message processed in {(int)total.TotalMilliseconds}ms, ({(int)userProcessTime.TotalMilliseconds}ms user code)");
_log.Write(LogLevel.Trace, $"Socket {SocketId}{(subscription == null ? "" : " subscription " + subscription!.Id)} message processed in {(int)total.TotalMilliseconds}ms, ({(int)userProcessTime.TotalMilliseconds}ms user code)");
}
/// <summary>
@ -368,12 +371,12 @@ namespace CryptoExchange.Net.Sockets
if (Status == SocketStatus.Closed || Status == SocketStatus.Disposed)
return;
if (socketClient.socketConnections.ContainsKey(SocketId))
socketClient.socketConnections.TryRemove(SocketId, out _);
if (ApiClient.socketConnections.ContainsKey(SocketId))
ApiClient.socketConnections.TryRemove(SocketId, out _);
lock (subscriptionLock)
lock (_subscriptionLock)
{
foreach (var subscription in subscriptions)
foreach (var subscription in _subscriptions)
{
if (subscription.CancellationTokenRegistration.HasValue)
subscription.CancellationTokenRegistration.Value.Dispose();
@ -391,9 +394,9 @@ namespace CryptoExchange.Net.Sockets
/// <returns></returns>
public async Task CloseAsync(SocketSubscription subscription)
{
lock (subscriptionLock)
lock (_subscriptionLock)
{
if (!subscriptions.Contains(subscription))
if (!_subscriptions.Contains(subscription))
return;
subscription.Closed = true;
@ -402,35 +405,35 @@ namespace CryptoExchange.Net.Sockets
if (Status == SocketStatus.Closing || Status == SocketStatus.Closed || Status == SocketStatus.Disposed)
return;
log.Write(LogLevel.Debug, $"Socket {SocketId} closing subscription {subscription.Id}");
_log.Write(LogLevel.Debug, $"Socket {SocketId} closing subscription {subscription.Id}");
if (subscription.CancellationTokenRegistration.HasValue)
subscription.CancellationTokenRegistration.Value.Dispose();
if (subscription.Confirmed && _socket.IsOpen)
await socketClient.UnsubscribeAsync(this, subscription).ConfigureAwait(false);
await ApiClient.UnsubscribeAsync(this, subscription).ConfigureAwait(false);
bool shouldCloseConnection;
lock (subscriptionLock)
lock (_subscriptionLock)
{
if (Status == SocketStatus.Closing)
{
log.Write(LogLevel.Debug, $"Socket {SocketId} already closing");
_log.Write(LogLevel.Debug, $"Socket {SocketId} already closing");
return;
}
shouldCloseConnection = subscriptions.All(r => !r.UserSubscription || r.Closed);
shouldCloseConnection = _subscriptions.All(r => !r.UserSubscription || r.Closed);
if (shouldCloseConnection)
Status = SocketStatus.Closing;
}
if (shouldCloseConnection)
{
log.Write(LogLevel.Debug, $"Socket {SocketId} closing as there are no more subscriptions");
_log.Write(LogLevel.Debug, $"Socket {SocketId} closing as there are no more subscriptions");
await CloseAsync().ConfigureAwait(false);
}
lock (subscriptionLock)
subscriptions.Remove(subscription);
lock (_subscriptionLock)
_subscriptions.Remove(subscription);
}
/// <summary>
@ -448,14 +451,14 @@ namespace CryptoExchange.Net.Sockets
/// <param name="subscription"></param>
public bool AddSubscription(SocketSubscription subscription)
{
lock (subscriptionLock)
lock (_subscriptionLock)
{
if (Status != SocketStatus.None && Status != SocketStatus.Connected)
return false;
subscriptions.Add(subscription);
_subscriptions.Add(subscription);
if(subscription.UserSubscription)
log.Write(LogLevel.Debug, $"Socket {SocketId} adding new subscription with id {subscription.Id}, total subscriptions on connection: {subscriptions.Count(s => s.UserSubscription)}");
_log.Write(LogLevel.Debug, $"Socket {SocketId} adding new subscription with id {subscription.Id}, total subscriptions on connection: {_subscriptions.Count(s => s.UserSubscription)}");
return true;
}
}
@ -466,8 +469,8 @@ namespace CryptoExchange.Net.Sockets
/// <param name="id"></param>
public SocketSubscription? GetSubscription(int id)
{
lock (subscriptionLock)
return subscriptions.SingleOrDefault(s => s.Id == id);
lock (_subscriptionLock)
return _subscriptions.SingleOrDefault(s => s.Id == id);
}
/// <summary>
@ -477,8 +480,8 @@ namespace CryptoExchange.Net.Sockets
/// <returns></returns>
public SocketSubscription? GetSubscriptionByRequest(Func<object?, bool> predicate)
{
lock(subscriptionLock)
return subscriptions.SingleOrDefault(s => predicate(s.Request));
lock(_subscriptionLock)
return _subscriptions.SingleOrDefault(s => predicate(s.Request));
}
/// <summary>
@ -496,15 +499,15 @@ namespace CryptoExchange.Net.Sockets
// Loop the subscriptions to check if any of them signal us that the message is for them
List<SocketSubscription> subscriptionsCopy;
lock (subscriptionLock)
subscriptionsCopy = subscriptions.ToList();
lock (_subscriptionLock)
subscriptionsCopy = _subscriptions.ToList();
foreach (var subscription in subscriptionsCopy)
{
currentSubscription = subscription;
if (subscription.Request == null)
{
if (socketClient.MessageMatchesHandler(this, messageEvent.JsonData, subscription.Identifier!))
if (ApiClient.MessageMatchesHandler(this, messageEvent.JsonData, subscription.Identifier!))
{
handled = true;
var userSw = Stopwatch.StartNew();
@ -515,10 +518,10 @@ namespace CryptoExchange.Net.Sockets
}
else
{
if (socketClient.MessageMatchesHandler(this, messageEvent.JsonData, subscription.Request))
if (ApiClient.MessageMatchesHandler(this, messageEvent.JsonData, subscription.Request))
{
handled = true;
messageEvent.JsonData = socketClient.ProcessTokenData(messageEvent.JsonData);
messageEvent.JsonData = ApiClient.ProcessTokenData(messageEvent.JsonData);
var userSw = Stopwatch.StartNew();
subscription.MessageHandler(messageEvent);
userSw.Stop();
@ -531,7 +534,7 @@ namespace CryptoExchange.Net.Sockets
}
catch (Exception ex)
{
log.Write(LogLevel.Error, $"Socket {SocketId} Exception during message processing\r\nException: {ex.ToLogString()}\r\nData: {messageEvent.JsonData}");
_log.Write(LogLevel.Error, $"Socket {SocketId} Exception during message processing\r\nException: {ex.ToLogString()}\r\nData: {messageEvent.JsonData}");
currentSubscription?.InvokeExceptionHandler(ex);
return (false, TimeSpan.Zero, null);
}
@ -548,9 +551,9 @@ namespace CryptoExchange.Net.Sockets
public virtual Task SendAndWaitAsync<T>(T obj, TimeSpan timeout, Func<JToken, bool> handler)
{
var pending = new PendingRequest(handler, timeout);
lock (pendingRequests)
lock (_pendingRequests)
{
pendingRequests.Add(pending);
_pendingRequests.Add(pending);
}
var sendOk = Send(obj);
if(!sendOk)
@ -579,7 +582,7 @@ namespace CryptoExchange.Net.Sockets
/// <param name="data">The data to send</param>
public virtual bool Send(string data)
{
log.Write(LogLevel.Trace, $"Socket {SocketId} sending data: {data}");
_log.Write(LogLevel.Trace, $"Socket {SocketId} sending data: {data}");
try
{
_socket.Send(data);
@ -597,36 +600,36 @@ namespace CryptoExchange.Net.Sockets
return new CallResult<bool>(new WebError("Socket not connected"));
bool anySubscriptions = false;
lock (subscriptionLock)
anySubscriptions = subscriptions.Any(s => s.UserSubscription);
lock (_subscriptionLock)
anySubscriptions = _subscriptions.Any(s => s.UserSubscription);
if (!anySubscriptions)
{
// No need to resubscribe anything
log.Write(LogLevel.Debug, $"Socket {SocketId} Nothing to resubscribe, closing connection");
_log.Write(LogLevel.Debug, $"Socket {SocketId} Nothing to resubscribe, closing connection");
_ = _socket.CloseAsync();
return new CallResult<bool>(true);
}
if (subscriptions.Any(s => s.Authenticated))
if (_subscriptions.Any(s => s.Authenticated))
{
// If we reconnected a authenticated connection we need to re-authenticate
var authResult = await socketClient.AuthenticateSocketAsync(this).ConfigureAwait(false);
var authResult = await ApiClient.AuthenticateSocketAsync(this).ConfigureAwait(false);
if (!authResult)
{
log.Write(LogLevel.Warning, $"Socket {SocketId} authentication failed on reconnected socket. Disconnecting and reconnecting.");
_log.Write(LogLevel.Warning, $"Socket {SocketId} authentication failed on reconnected socket. Disconnecting and reconnecting.");
return authResult;
}
Authenticated = true;
log.Write(LogLevel.Debug, $"Socket {SocketId} authentication succeeded on reconnected socket.");
_log.Write(LogLevel.Debug, $"Socket {SocketId} authentication succeeded on reconnected socket.");
}
// Get a list of all subscriptions on the socket
List<SocketSubscription> subscriptionList = new List<SocketSubscription>();
lock (subscriptionLock)
lock (_subscriptionLock)
{
foreach (var subscription in subscriptions)
foreach (var subscription in _subscriptions)
{
if (subscription.Request != null)
subscriptionList.Add(subscription);
@ -635,15 +638,25 @@ namespace CryptoExchange.Net.Sockets
}
}
foreach(var subscription in subscriptionList.Where(s => s.Request != null))
{
var result = await ApiClient.RevitalizeRequestAsync(subscription.Request!).ConfigureAwait(false);
if (!result)
{
_log.Write(LogLevel.Warning, "Failed request revitalization: " + result.Error);
return result.As<bool>(false);
}
}
// Foreach subscription which is subscribed by a subscription request we will need to resend that request to resubscribe
for (var i = 0; i < subscriptionList.Count; i += socketClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket)
for (var i = 0; i < subscriptionList.Count; i += ApiClient.Options.MaxConcurrentResubscriptionsPerSocket)
{
if (!_socket.IsOpen)
return new CallResult<bool>(new WebError("Socket not connected"));
var taskList = new List<Task<CallResult<bool>>>();
foreach (var subscription in subscriptionList.Skip(i).Take(socketClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket))
taskList.Add(socketClient.SubscribeAndWaitAsync(this, subscription.Request!, subscription));
foreach (var subscription in subscriptionList.Skip(i).Take(ApiClient.Options.MaxConcurrentResubscriptionsPerSocket))
taskList.Add(ApiClient.SubscribeAndWaitAsync(this, subscription.Request!, subscription));
await Task.WhenAll(taskList).ConfigureAwait(false);
if (taskList.Any(t => !t.Result.Success))
@ -656,13 +669,13 @@ namespace CryptoExchange.Net.Sockets
if (!_socket.IsOpen)
return new CallResult<bool>(new WebError("Socket not connected"));
log.Write(LogLevel.Debug, $"Socket {SocketId} all subscription successfully resubscribed on reconnected socket.");
_log.Write(LogLevel.Debug, $"Socket {SocketId} all subscription successfully resubscribed on reconnected socket.");
return new CallResult<bool>(true);
}
internal async Task UnsubscribeAsync(SocketSubscription socketSubscription)
{
await socketClient.UnsubscribeAsync(this, socketSubscription).ConfigureAwait(false);
await ApiClient.UnsubscribeAsync(this, socketSubscription).ConfigureAwait(false);
}
internal async Task<CallResult<bool>> ResubscribeAsync(SocketSubscription socketSubscription)
@ -670,7 +683,7 @@ namespace CryptoExchange.Net.Sockets
if (!_socket.IsOpen)
return new CallResult<bool>(new UnknownError("Socket is not connected"));
return await socketClient.SubscribeAndWaitAsync(this, socketSubscription.Request!, socketSubscription).ConfigureAwait(false);
return await ApiClient.SubscribeAndWaitAsync(this, socketSubscription.Request!, socketSubscription).ConfigureAwait(false);
}
/// <summary>

View File

@ -33,6 +33,14 @@ Make a one time donation in a crypto currency of your choice. If you prefer to d
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf).
## Release notes
* 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