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

Compare commits

..

70 Commits

Author SHA1 Message Date
JKorf 68067d6258 Updated to version 7.10.0 2024-07-26 14:29:07 +02:00
JKorf b309deb0c4 Small fixed/improvements. Added deflate stream method 2024-07-25 18:47:33 +02:00
JKorf e1dafdf0dd Added AddSecondsString and AddOptionalSecondsString method to ParameterCollection 2024-07-24 10:22:35 +02:00
JKorf fd7b5f0f0f Added RestIntergrationTest base class for running integration tests 2024-07-23 16:58:10 +02:00
JKorf 3b735d66fd Improved socket reconnect logic so unsubscribing while processing resubscribing won't result in a ghost subscription 2024-07-23 12:01:31 +02:00
JKorf 3cd505ac8b Fixed order book logging 2024-07-23 12:00:15 +02:00
JKorf 81d856d78d Fixed System.Text.Json array converter deserialization when skipping indexes, added NumberStringConverter 2024-07-23 12:00:08 +02:00
JKorf 11c1ad871a Docs 2024-07-19 10:15:21 +02:00
JKorf ffcb7db8ff Updated to version 7.9.0 2024-07-16 19:01:03 +02:00
JKorf 02432e5109 Added small performance improvements in SystemTextJsonMessageAccessor 2024-07-16 15:54:11 +02:00
JKorf a85bfb4432 Added support for requiring multiple responses for queries, fixed possible exception when closing connection, added ToString override DataEvent object 2024-07-16 15:23:09 +02:00
JKorf 17d85fdd85 Added WebCallResult As<T> methods for untyped WebCallResult 2024-07-16 08:35:19 +02:00
JKorf 5e0733d7f4 Fixed trying to authenticate dedicated connection when no credentials are set 2024-07-14 20:59:22 +02:00
JKorf 28d5287bd4 Changed logging extensions to public so overridden methods can access them 2024-07-11 19:19:35 +02:00
JKorf ef5097589a Test compare improvements 2024-07-10 16:57:30 +02:00
JKorf f287ec1fa4 Added some checks socket connections 2024-07-10 14:12:07 +02:00
JKorf 28da93af9d Test fixes 2024-07-10 10:18:58 +02:00
JKorf 0d5bdf5095 Bumped System.Text.Json package to 8.0.4 2024-07-10 10:09:36 +02:00
JKorf 8dac3d7aa6 Refactor to prevent usage of thread block ManualResetEvent in socket message handling 2024-07-09 16:50:08 +02:00
JKorf 6951f31be7 Fix ArrayConverter error for nullable types 2024-07-05 16:34:58 +02:00
JKorf 630f85ec49 Updated to version 7.8.0 2024-07-02 20:17:01 +02:00
JKorf 9ec4f2276f Updated single endpoint limit configuration, added LongConverter, updated SystemTextJsonComparer logic 2024-07-02 16:13:10 +02:00
JKorf 0a0c66541e Updated to version 7.7.3 2024-06-26 19:11:13 +02:00
JKorf bb4199620e Added caching docs 2024-06-26 15:33:31 +02:00
JKorf 8a83cd2cb8 Array comparison updates for unit tests 2024-06-26 15:13:21 +02:00
JKorf fcfeaf568f Fixed request ids not matching 2024-06-26 11:21:52 +02:00
JKorf 25567ea434 Added nullable int converter for System.Text.Json 2024-06-25 20:52:35 +02:00
JKorf 1ab85d4c26 Updated to version 7.7.2 2024-06-25 16:42:45 +02:00
JKorf be68115099 Fix for ratelimiting possibly creating negative waits 2024-06-25 16:14:09 +02:00
JKorf ff0550b0fb Updated to version 7.7.1 2024-06-23 22:40:13 +02:00
JKorf 1ab1e008fc Some fixes for caching 2024-06-23 22:39:54 +02:00
JKorf 6f30c72608 Updated to version 7.7.0 2024-06-23 15:38:40 +02:00
JKorf e927bc3d20 Close socket when connecting and authentication fails 2024-06-23 14:55:20 +02:00
JKorf 09ed7d1436 Small improvements 2024-06-23 14:41:59 +02:00
JKorf 6fed657ea6 Added ObjectStringConverter for double serialized objects 2024-06-21 16:49:02 +02:00
Jonnern 1555f8da0c Fix issue with rate limit guard waiting sub ms (#204) 2024-06-21 16:47:14 +02:00
JKorf 68b28fc875 Small improvements 2024-06-19 17:01:26 +02:00
JKorf 5d50d8cde8 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2024-06-17 17:29:57 +02:00
JKorf 9ff673d8be Fixed unhandled exception when websocket connection is interupted while waiting for ratelimit 2024-06-17 10:53:22 +02:00
JKorf 3e5a34fb56 Added dedicated request websocket connection support 2024-06-16 16:55:44 +02:00
JKorf 64ee50d98c Added SocketConnection as parameter to GetAuthenticationRequest socket api client 2024-06-14 14:06:57 +02:00
JKorf 6a105c6f8f Added support for caching GET requests 2024-06-13 16:29:02 +02:00
JKorf 287aadc720 Added CancellationToken support for websocket queries 2024-06-13 11:58:52 +02:00
JKorf 7229438a0b Docs 2024-06-12 21:42:20 +02:00
JKorf 444af98a15 Added GateIo reference 2024-06-12 19:23:34 +02:00
JKorf 70c6fa1bbb Fixed tests 2024-06-12 18:05:26 +02:00
JKorf d27f394b46 Removed HandleUpdatesBeforeConfirmation flag, allow messages to trigger listeners even if not confirmed and mark as confirmed then. Updated websocket reconnection delay handling 2024-06-12 16:56:06 +02:00
JKorf c8c98e13d0 Updated to version 7.6.0 2024-06-11 16:32:28 +02:00
Jan Korf 9fcd722991 Feature/body uri param split (#203)
* Added support for specifying seperate uri and body parameters
* Added support for different message and handling generic types on socket queries
* Split DataEvent.Topic into StreamId and Symbol properties
* Added support for negative time values parsing
* Added some helper methods for converting DataEvent to CallResult
* Added support for GZip/Deflate automatic decompressing in the default HttpClient
* Updated some testing methods
2024-06-11 16:23:48 +02:00
JKorf 8080ecccc0 Added support for Patch requests, added SetBody to ParameterCollection for directly setting the request body 2024-06-04 09:54:24 +02:00
JKorf 4b6fa9a1b1 Delete release.yml 2024-06-02 09:38:23 +02:00
JKorf 0b6dbde7d4 docs 2024-06-01 22:56:43 +02:00
JKorf fe4d63ba75 Update index.html 2024-06-01 22:17:26 +02:00
JKorf 04bd3727ca Updated version 2024-05-07 09:56:59 +02:00
JKorf 7e6fcd03c2 Update release.yml 2024-05-07 09:50:22 +02:00
JKorf fde8d6353b Fixed SetApiCredentials not correctly being used by rate limiter causing exception 2024-05-07 09:43:37 +02:00
JKorf 41b996168a Create release.yml 2024-05-05 14:17:13 +02:00
JKorf b26f8fb900 Updated version 2024-05-03 16:05:44 +02:00
JKorf bdbbc61d86 More supported testing comparing 2024-05-03 16:03:49 +02:00
JKorf d64e200f2f Test implementation fixes 2024-05-02 22:29:32 +02:00
JKorf 71d54e2f9a Updated version 2024-05-01 19:54:07 +02:00
JKorf ba3975993f Added multilayer nested compare support for testing 2024-05-01 19:49:14 +02:00
Jan Korf 050286ecd1 Unit testing update (#199) 2024-05-01 19:24:53 +02:00
JKorf 96c9a55c48 Update index.html 2024-04-29 15:33:09 +02:00
JKorf a20cbb2f1c Update index.html 2024-04-29 14:57:20 +02:00
JKorf 2e957d7d9e Docs 2024-04-29 14:40:34 +02:00
JKorf 18d0341056 Update README.md 2024-04-28 14:10:00 +02:00
JKorf a2bfed2433 Update README.md 2024-04-28 14:09:37 +02:00
JKorf 67299338a8 Updated version 2024-04-28 10:59:59 +02:00
Jan Korf 971c049c5f Feature/cryptoclients update (#198) 2024-04-28 10:56:51 +02:00
84 changed files with 3797 additions and 756 deletions
@@ -121,6 +121,7 @@ namespace CryptoExchange.Net.UnitTests
null,
HttpMethod.Get,
new List<KeyValuePair<string, IEnumerable<string>>>(),
ResultDataSource.Server,
new TestObjectResult(),
null);
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
@@ -150,6 +151,7 @@ namespace CryptoExchange.Net.UnitTests
null,
HttpMethod.Get,
new List<KeyValuePair<string, IEnumerable<string>>>(),
ResultDataSource.Server,
new TestObjectResult(),
null);
var asResult = result.As<TestObject2>(result.Data.InnerData);
@@ -58,9 +58,7 @@ namespace CryptoExchange.Net.UnitTests
options.ReconnectInterval = TimeSpan.Zero;
});
var socket = client.CreateSocket();
socket.ShouldReconnect = true;
socket.CanConnect = true;
socket.DisconnectTime = DateTime.UtcNow;
var sub = new SocketConnection(new TraceLogger(), client.SubClient, socket, null);
var rstEvent = new ManualResetEvent(false);
Dictionary<string, string> result = null;
@@ -72,11 +70,10 @@ namespace CryptoExchange.Net.UnitTests
result = messageEvent.Data;
rstEvent.Set();
});
subObj.HandleUpdatesBeforeConfirmation = true;
sub.AddSubscription(subObj);
// act
socket.InvokeMessage("{\"property\": \"123\", \"topic\": \"topic\"}");
socket.InvokeMessage("{\"property\": \"123\", \"action\": \"update\", \"topic\": \"topic\"}");
rstEvent.WaitOne(1000);
// assert
@@ -94,9 +91,7 @@ namespace CryptoExchange.Net.UnitTests
options.SubOptions.OutputOriginalData = enabled;
});
var socket = client.CreateSocket();
socket.ShouldReconnect = true;
socket.CanConnect = true;
socket.DisconnectTime = DateTime.UtcNow;
var sub = new SocketConnection(new TraceLogger(), client.SubClient, socket, null);
var rstEvent = new ManualResetEvent(false);
string original = null;
@@ -107,9 +102,8 @@ namespace CryptoExchange.Net.UnitTests
original = messageEvent.OriginalData;
rstEvent.Set();
});
subObj.HandleUpdatesBeforeConfirmation = true;
sub.AddSubscription(subObj);
var msgToSend = JsonConvert.SerializeObject(new { topic = "topic", property = 123 });
var msgToSend = JsonConvert.SerializeObject(new { topic = "topic", action = "update", property = 123 });
// act
socket.InvokeMessage(msgToSend);
@@ -204,7 +198,7 @@ namespace CryptoExchange.Net.UnitTests
// act
var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default);
socket.InvokeMessage(JsonConvert.SerializeObject(new { channel, status = "error" }));
socket.InvokeMessage(JsonConvert.SerializeObject(new { channel, action = "subscribe", status = "error" }));
await sub;
// assert
@@ -227,7 +221,7 @@ namespace CryptoExchange.Net.UnitTests
// act
var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default);
socket.InvokeMessage(JsonConvert.SerializeObject(new { channel, status = "confirmed" }));
socket.InvokeMessage(JsonConvert.SerializeObject(new { channel, action = "subscribe", status = "confirmed" }));
await sub;
// assert
@@ -10,6 +10,10 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
{
internal class SubResponse
{
[JsonProperty("action")]
public string Action { get; set; } = null!;
[JsonProperty("channel")]
public string Channel { get; set; } = null!;
@@ -19,6 +23,9 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
internal class UnsubResponse
{
[JsonProperty("action")]
public string Action { get; set; } = null!;
[JsonProperty("status")]
public string Status { get; set; } = null!;
}
@@ -29,7 +36,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
public TestChannelQuery(string channel, string request, bool authenticated, int weight = 1) : base(request, authenticated, weight)
{
ListenerIdentifiers = new HashSet<string> { channel };
ListenerIdentifiers = new HashSet<string> { request + "-" + channel };
}
public override CallResult<SubResponse> HandleMessage(SocketConnection connection, DataEvent<SubResponse> message)
@@ -15,7 +15,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
{
private readonly Action<DataEvent<T>> _handler;
public override HashSet<string> ListenerIdentifiers { get; set; } = new HashSet<string> { "topic" };
public override HashSet<string> ListenerIdentifiers { get; set; } = new HashSet<string> { "update-topic" };
public TestSubscription(ILogger logger, Action<DataEvent<T>> handler) : base(logger, false)
{
@@ -54,6 +54,8 @@ namespace CryptoExchange.Net.UnitTests
return deserializeResult;
}
/// <inheritdoc />
public override string FormatSymbol(string baseAsset, string quoteAsset) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
public override TimeSpan? GetTimeOffset() => null;
public override TimeSyncInfo GetTimeSyncInfo() => null;
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException();
@@ -66,11 +68,8 @@ namespace CryptoExchange.Net.UnitTests
{
}
public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, Dictionary<string, object> providedParameters, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, RequestBodyFormat bodyFormat, out SortedDictionary<string, object> uriParameters, out SortedDictionary<string, object> bodyParameters, out Dictionary<string, string> headers)
public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, IDictionary<string, object> uriParams, IDictionary<string, object> bodyParams, Dictionary<string, string> headers, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, RequestBodyFormat bodyFormat)
{
bodyParameters = new SortedDictionary<string, object>();
uriParameters = new SortedDictionary<string, object>();
headers = new Dictionary<string, string>();
}
public string GetKey() => _credentials.Key.GetString();
@@ -137,6 +137,9 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
RequestFactory = new Mock<IRequestFactory>().Object;
}
/// <inheritdoc />
public override string FormatSymbol(string baseAsset, string quoteAsset) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
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, requestWeight: 0);
@@ -178,6 +181,9 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
RequestFactory = new Mock<IRequestFactory>().Object;
}
/// <inheritdoc />
public override string FormatSymbol(string baseAsset, string quoteAsset) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
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, requestWeight: 0);
@@ -1,131 +1,132 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Security.Authentication;
using System.Text;
using System.Threading.Tasks;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
//using System;
//using System.IO;
//using System.Net.WebSockets;
//using System.Security.Authentication;
//using System.Text;
//using System.Threading.Tasks;
//using CryptoExchange.Net.Interfaces;
//using CryptoExchange.Net.Objects;
namespace CryptoExchange.Net.UnitTests.TestImplementations
{
public class TestSocket: IWebsocket
{
public bool CanConnect { get; set; }
public bool Connected { get; set; }
//namespace CryptoExchange.Net.UnitTests.TestImplementations
//{
// public class TestSocket: IWebsocket
// {
// public bool CanConnect { get; set; }
// public bool Connected { get; set; }
public event Func<Task> OnClose;
#pragma warning disable 0067
public event Func<Task> OnReconnected;
public event Func<Task> OnReconnecting;
public event Func<int, Task> OnRequestRateLimited;
#pragma warning restore 0067
public event Func<int, Task> OnRequestSent;
public event Action<WebSocketMessageType, ReadOnlyMemory<byte>> OnStreamMessage;
public event Func<Exception, Task> OnError;
public event Func<Task> OnOpen;
public Func<Task<Uri>> GetReconnectionUrl { get; set; }
// public event Func<Task> OnClose;
//#pragma warning disable 0067
// public event Func<Task> OnReconnected;
// public event Func<Task> OnReconnecting;
// public event Func<int, Task> OnRequestRateLimited;
//#pragma warning restore 0067
// public event Func<int, Task> OnRequestSent;
// public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task> OnStreamMessage;
// public event Func<Exception, Task> OnError;
// public event Func<Task> OnOpen;
// public Func<Task<Uri>> GetReconnectionUrl { get; set; }
public int Id { get; }
public bool ShouldReconnect { get; set; }
public TimeSpan Timeout { get; set; }
public Func<string, string> DataInterpreterString { get; set; }
public Func<byte[], string> DataInterpreterBytes { get; set; }
public DateTime? DisconnectTime { get; set; }
public string Url { get; }
public bool IsClosed => !Connected;
public bool IsOpen => Connected;
public bool PingConnection { get; set; }
public TimeSpan PingInterval { get; set; }
public SslProtocols SSLProtocols { get; set; }
public Encoding Encoding { get; set; }
// public int Id { get; }
// public bool ShouldReconnect { get; set; }
// public TimeSpan Timeout { get; set; }
// public Func<string, string> DataInterpreterString { get; set; }
// public Func<byte[], string> DataInterpreterBytes { get; set; }
// public DateTime? DisconnectTime { get; set; }
// public string Url { get; }
// public bool IsClosed => !Connected;
// public bool IsOpen => Connected;
// public bool PingConnection { get; set; }
// public TimeSpan PingInterval { get; set; }
// public SslProtocols SSLProtocols { get; set; }
// public Encoding Encoding { get; set; }
public int ConnectCalls { get; private set; }
public bool Reconnecting { get; set; }
public string Origin { get; set; }
public int? RatelimitPerSecond { get; set; }
// public int ConnectCalls { get; private set; }
// public bool Reconnecting { get; set; }
// public string Origin { get; set; }
// public int? RatelimitPerSecond { get; set; }
public double IncomingKbps => throw new NotImplementedException();
// public double IncomingKbps => throw new NotImplementedException();
public Uri Uri => new Uri("");
// public Uri Uri => new Uri("");
public TimeSpan KeepAliveInterval { get; set; }
// public TimeSpan KeepAliveInterval { get; set; }
public static int lastId = 0;
public static object lastIdLock = new object();
// public static int lastId = 0;
// public static object lastIdLock = new object();
public TestSocket()
{
lock (lastIdLock)
{
Id = lastId + 1;
lastId++;
}
}
// public TestSocket()
// {
// lock (lastIdLock)
// {
// Id = lastId + 1;
// lastId++;
// }
// }
public Task<CallResult> ConnectAsync()
{
Connected = CanConnect;
ConnectCalls++;
if (CanConnect)
InvokeOpen();
return Task.FromResult(CanConnect ? new CallResult(null) : new CallResult(new CantConnectError()));
}
// public Task<CallResult> ConnectAsync()
// {
// Connected = CanConnect;
// ConnectCalls++;
// if (CanConnect)
// InvokeOpen();
// return Task.FromResult(CanConnect ? new CallResult(null) : new CallResult(new CantConnectError()));
// }
public void Send(int requestId, string data, int weight)
{
if(!Connected)
throw new Exception("Socket not connected");
OnRequestSent?.Invoke(requestId);
}
// public bool Send(int requestId, string data, int weight)
// {
// if(!Connected)
// throw new Exception("Socket not connected");
// OnRequestSent?.Invoke(requestId);
// return true;
// }
public void Reset()
{
}
// public void Reset()
// {
// }
public Task CloseAsync()
{
Connected = false;
DisconnectTime = DateTime.UtcNow;
OnClose?.Invoke();
return Task.FromResult(0);
}
// public Task CloseAsync()
// {
// Connected = false;
// DisconnectTime = DateTime.UtcNow;
// OnClose?.Invoke();
// return Task.FromResult(0);
// }
public void SetProxy(string host, int port)
{
throw new NotImplementedException();
}
public void Dispose()
{
}
// public void SetProxy(string host, int port)
// {
// throw new NotImplementedException();
// }
// public void Dispose()
// {
// }
public void InvokeClose()
{
Connected = false;
DisconnectTime = DateTime.UtcNow;
Reconnecting = true;
OnClose?.Invoke();
}
// public void InvokeClose()
// {
// Connected = false;
// DisconnectTime = DateTime.UtcNow;
// Reconnecting = true;
// OnClose?.Invoke();
// }
public void InvokeOpen()
{
OnOpen?.Invoke();
}
// public void InvokeOpen()
// {
// OnOpen?.Invoke();
// }
public void InvokeMessage(string data)
{
OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes(data)));
}
// public void InvokeMessage(string data)
// {
// OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes(data))).Wait();
// }
public void SetProxy(ApiProxy proxy)
{
throw new NotImplementedException();
}
// public void SetProxy(ApiProxy proxy)
// {
// throw new NotImplementedException();
// }
public void InvokeError(Exception error)
{
OnError?.Invoke(error);
}
public Task ReconnectAsync() => Task.CompletedTask;
}
}
// public void InvokeError(Exception error)
// {
// OnError?.Invoke(error);
// }
// public Task ReconnectAsync() => Task.CompletedTask;
// }
//}
@@ -13,11 +13,11 @@ using CryptoExchange.Net.Sockets;
using CryptoExchange.Net.UnitTests.TestImplementations.Sockets;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json.Linq;
using CryptoExchange.Net.Testing.Implementations;
namespace CryptoExchange.Net.UnitTests.TestImplementations
{
public class TestSocketClient: BaseSocketClient
internal class TestSocketClient: BaseSocketClient
{
public TestSubSocketClient SubClient { get; }
@@ -41,12 +41,12 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
SubClient = AddApiClient(new TestSubSocketClient(options, options.SubOptions));
SubClient.SocketFactory = new Mock<IWebsocketFactory>().Object;
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<ILogger>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket());
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<ILogger>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket("https://test.com"));
}
public TestSocket CreateSocket()
{
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<ILogger>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket());
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<ILogger>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket("https://test.com"));
return (TestSocket)SubClient.CreateSocketInternal("https://localhost:123/");
}
@@ -75,6 +75,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
public class TestSubSocketClient : SocketApiClient
{
private MessagePath _channelPath = MessagePath.Get().Property("channel");
private MessagePath _actionPath = MessagePath.Get().Property("action");
private MessagePath _topicPath = MessagePath.Get().Property("topic");
public Subscription TestSubscription { get; private set; } = null;
@@ -84,6 +85,9 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
}
/// <inheritdoc />
public override string FormatSymbol(string baseAsset, string quoteAsset) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
internal IWebsocket CreateSocketInternal(string address)
{
return CreateSocket(address);
@@ -107,7 +111,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
var id = message.GetValue<string>(_channelPath);
id ??= message.GetValue<string>(_topicPath);
return id;
return message.GetValue<string>(_actionPath) + "-" + id;
}
public Task<CallResult<UpdateSubscription>> SubscribeToSomethingAsync(string channel, Action<DataEvent<string>> onUpdate, CancellationToken ct)
@@ -1,5 +1,6 @@
using CryptoExchange.Net.Clients;
using CryptoExchange.Net.Converters.SystemTextJson;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using System;
using System.Collections.Generic;
@@ -15,10 +16,12 @@ namespace CryptoExchange.Net.Authentication
/// </summary>
public abstract class AuthenticationProvider : IDisposable
{
internal IAuthTimeProvider TimeProvider { get; set; } = new AuthTimeProvider();
/// <summary>
/// Provided credentials
/// </summary>
protected readonly ApiCredentials _credentials;
protected internal readonly ApiCredentials _credentials;
/// <summary>
/// Byte representation of the secret
@@ -44,26 +47,24 @@ namespace CryptoExchange.Net.Authentication
/// <param name="apiClient">The Api client sending the request</param>
/// <param name="uri">The uri for the request</param>
/// <param name="method">The method of the request</param>
/// <param name="providedParameters">The request parameters</param>
/// <param name="auth">If the requests should be authenticated</param>
/// <param name="arraySerialization">Array serialization type</param>
/// <param name="parameterPosition">The position where the providedParameters should go</param>
/// <param name="requestBodyFormat">The formatting of the request body</param>
/// <param name="uriParameters">Parameters that need to be in the Uri of the request. Should include the provided parameters if they should go in the uri</param>
/// <param name="bodyParameters">Parameters that need to be in the body of the request. Should include the provided parameters if they should go in the body</param>
/// <param name="headers">The headers that should be send with the request</param>
/// <param name="parameterPosition">The position where the providedParameters should go</param>
public abstract void AuthenticateRequest(
RestApiClient apiClient,
Uri uri,
HttpMethod method,
Dictionary<string, object> providedParameters,
IDictionary<string, object> uriParameters,
IDictionary<string, object> bodyParameters,
Dictionary<string, string> headers,
bool auth,
ArrayParametersSerialization arraySerialization,
HttpMethodParameterPosition parameterPosition,
RequestBodyFormat requestBodyFormat,
out SortedDictionary<string, object> uriParameters,
out SortedDictionary<string, object> bodyParameters,
out Dictionary<string, string> headers
RequestBodyFormat requestBodyFormat
);
/// <summary>
@@ -418,9 +419,9 @@ namespace CryptoExchange.Net.Authentication
/// </summary>
/// <param name="apiClient"></param>
/// <returns></returns>
protected static DateTime GetTimestamp(RestApiClient apiClient)
protected DateTime GetTimestamp(RestApiClient apiClient)
{
return DateTime.UtcNow.Add(apiClient.GetTimeOffset() ?? TimeSpan.Zero)!;
return TimeProvider.GetTime().Add(apiClient.GetTimeOffset() ?? TimeSpan.Zero)!;
}
/// <summary>
@@ -428,11 +429,25 @@ namespace CryptoExchange.Net.Authentication
/// </summary>
/// <param name="apiClient"></param>
/// <returns></returns>
protected static string GetMillisecondTimestamp(RestApiClient apiClient)
protected string GetMillisecondTimestamp(RestApiClient apiClient)
{
return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// Return the serialized request body
/// </summary>
/// <param name="serializer"></param>
/// <param name="parameters"></param>
/// <returns></returns>
protected string GetSerializedBody(IMessageSerializer serializer, IDictionary<string, object> parameters)
{
if (parameters.Count == 1 && parameters.ContainsKey(Constants.BodyPlaceHolderKey))
return serializer.Serialize(parameters[Constants.BodyPlaceHolderKey]);
else
return serializer.Serialize(parameters);
}
/// <inheritdoc />
public void Dispose()
{
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Collections.Concurrent;
namespace CryptoExchange.Net.Caching
{
internal class MemoryCache
{
private readonly ConcurrentDictionary<string, CacheItem> _cache = new ConcurrentDictionary<string, CacheItem>();
/// <summary>
/// Add a new cache entry. Will override an existing entry if it already exists
/// </summary>
/// <param name="key">The key identifier</param>
/// <param name="value">Cache value</param>
public void Add(string key, object value)
{
var cacheItem = new CacheItem(DateTime.UtcNow, value);
_cache.AddOrUpdate(key, cacheItem, (key, val1) => cacheItem);
}
/// <summary>
/// Get a cached value
/// </summary>
/// <param name="key">The key identifier</param>
/// <param name="maxAge">The max age of the cached entry</param>
/// <returns>Cached value if it was in cache</returns>
public object? Get(string key, TimeSpan maxAge)
{
_cache.TryGetValue(key, out CacheItem value);
if (value == null)
return null;
if (DateTime.UtcNow - value.CacheTime > maxAge)
{
_cache.TryRemove(key, out _);
return null;
}
return value.Value;
}
private class CacheItem
{
public DateTime CacheTime { get; }
public object Value { get; }
public CacheItem(DateTime cacheTime, object value)
{
CacheTime = cacheTime;
Value = value;
}
}
}
}
@@ -78,6 +78,9 @@ namespace CryptoExchange.Net.Clients
/// <returns></returns>
protected abstract AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials);
/// <inheritdoc />
public abstract string FormatSymbol(string baseAsset, string quoteAsset);
/// <inheritdoc />
public void SetApiCredentials<T>(T credentials) where T : ApiCredentials
{
+182 -75
View File
@@ -8,6 +8,7 @@ using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using CryptoExchange.Net.Caching;
using CryptoExchange.Net.Converters.JsonNet;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Logging.Extensions;
@@ -17,6 +18,7 @@ using CryptoExchange.Net.RateLimiting;
using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.Requests;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace CryptoExchange.Net.Clients
{
@@ -40,23 +42,33 @@ namespace CryptoExchange.Net.Clients
/// <summary>
/// Request body content type
/// </summary>
protected RequestBodyFormat RequestBodyFormat = RequestBodyFormat.Json;
protected internal RequestBodyFormat RequestBodyFormat = RequestBodyFormat.Json;
/// <summary>
/// How to serialize array parameters when making requests
/// </summary>
protected ArrayParametersSerialization ArraySerialization = ArrayParametersSerialization.Array;
protected internal ArrayParametersSerialization ArraySerialization = ArrayParametersSerialization.Array;
/// <summary>
/// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody)
/// </summary>
protected string RequestBodyEmptyContent = "{}";
protected internal string RequestBodyEmptyContent = "{}";
/// <summary>
/// Request headers to be sent with each request
/// </summary>
protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
/// <summary>
/// Whether parameters need to be ordered
/// </summary>
protected internal bool OrderParameters { get; set; } = true;
/// <summary>
/// Parameter order comparer
/// </summary>
protected IComparer<string> ParameterOrderComparer { get; } = new OrderedStringComparer();
/// <summary>
/// Where to put the parameters for requests with different Http methods
/// </summary>
@@ -65,7 +77,8 @@ namespace CryptoExchange.Net.Clients
{ HttpMethod.Get, HttpMethodParameterPosition.InUri },
{ HttpMethod.Post, HttpMethodParameterPosition.InBody },
{ HttpMethod.Delete, HttpMethodParameterPosition.InBody },
{ HttpMethod.Put, HttpMethodParameterPosition.InBody }
{ HttpMethod.Put, HttpMethodParameterPosition.InBody },
{ new HttpMethod("Patch"), HttpMethodParameterPosition.InBody },
};
/// <inheritdoc />
@@ -74,6 +87,10 @@ namespace CryptoExchange.Net.Clients
/// <inheritdoc />
public new RestApiOptions ApiOptions => (RestApiOptions)base.ApiOptions;
/// <summary>
/// Memory cache
/// </summary>
private static MemoryCache _cache = new MemoryCache();
/// <summary>
/// ctor
@@ -139,7 +156,7 @@ namespace CryptoExchange.Net.Clients
/// <param name="additionalHeaders">Additional headers for this request</param>
/// <param name="weight">Override the request weight for this request definition, for example when the weight depends on the parameters</param>
/// <returns></returns>
protected virtual async Task<WebCallResult<T>> SendAsync<T>(
protected virtual Task<WebCallResult<T>> SendAsync<T>(
string baseAddress,
RequestDefinition definition,
ParameterCollection? parameters,
@@ -147,16 +164,71 @@ namespace CryptoExchange.Net.Clients
Dictionary<string, string>? additionalHeaders = null,
int? weight = null) where T : class
{
var parameterPosition = definition.ParameterPosition ?? ParameterPositions[definition.Method];
return SendAsync<T>(
baseAddress,
definition,
parameterPosition == HttpMethodParameterPosition.InUri ? parameters : null,
parameterPosition == HttpMethodParameterPosition.InBody ? parameters : null,
cancellationToken,
additionalHeaders,
weight);
}
/// <summary>
/// Send a request to the base address based on the request definition
/// </summary>
/// <typeparam name="T">Response type</typeparam>
/// <param name="baseAddress">Host and schema</param>
/// <param name="definition">Request definition</param>
/// <param name="uriParameters">Request query parameters</param>
/// <param name="bodyParameters">Request body parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="additionalHeaders">Additional headers for this request</param>
/// <param name="weight">Override the request weight for this request definition, for example when the weight depends on the parameters</param>
/// <returns></returns>
protected virtual async Task<WebCallResult<T>> SendAsync<T>(
string baseAddress,
RequestDefinition definition,
ParameterCollection? uriParameters,
ParameterCollection? bodyParameters,
CancellationToken cancellationToken,
Dictionary<string, string>? additionalHeaders = null,
int? weight = null) where T : class
{
var key = baseAddress + definition + uriParameters?.ToFormData();
if (ShouldCache(definition))
{
_logger.CheckingCache(key);
var cachedValue = _cache.Get(key, ClientOptions.CachingMaxAge);
if (cachedValue != null)
{
_logger.CacheHit(key);
var original = (WebCallResult<T>)cachedValue;
return original.Cached();
}
_logger.CacheNotHit(key);
}
int currentTry = 0;
while (true)
{
currentTry++;
var prepareResult = await PrepareAsync(baseAddress, definition, parameters, cancellationToken, additionalHeaders, weight).ConfigureAwait(false);
var requestId = ExchangeHelpers.NextId();
var prepareResult = await PrepareAsync(requestId, baseAddress, definition, cancellationToken, additionalHeaders, weight).ConfigureAwait(false);
if (!prepareResult)
return new WebCallResult<T>(prepareResult.Error!);
var request = CreateRequest(baseAddress, definition, parameters, additionalHeaders);
_logger.RestApiSendRequest(request.RequestId, definition, request.Content, request.Uri.Query, string.Join(", ", request.GetHeaders().Select(h => h.Key + $"=[{string.Join(",", h.Value)}]")));
var request = CreateRequest(
requestId,
baseAddress,
definition,
uriParameters,
bodyParameters,
additionalHeaders);
_logger.RestApiSendRequest(request.RequestId, definition, request.Content, string.IsNullOrEmpty(request.Uri.Query) ? "-" : request.Uri.Query, string.Join(", ", request.GetHeaders().Select(h => h.Key + $"=[{string.Join(",", h.Value)}]")));
TotalRequestsMade++;
var result = await GetResponseAsync<T>(request, definition.RateLimitGate, cancellationToken).ConfigureAwait(false);
if (!result)
@@ -167,6 +239,12 @@ namespace CryptoExchange.Net.Clients
if (await ShouldRetryRequestAsync(definition.RateLimitGate, result, currentTry).ConfigureAwait(false))
continue;
if (result.Success &&
ShouldCache(definition))
{
_cache.Add(key, result);
}
return result;
}
}
@@ -174,23 +252,22 @@ namespace CryptoExchange.Net.Clients
/// <summary>
/// Prepare before sending a request. Sync time between client and server and check rate limits
/// </summary>
/// <param name="requestId">Request id</param>
/// <param name="baseAddress">Host and schema</param>
/// <param name="definition">Request definition</param>
/// <param name="parameters">Request parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="additionalHeaders">Additional headers for this request</param>
/// <param name="weight">Override the request weight for this request</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
protected virtual async Task<CallResult> PrepareAsync(
int requestId,
string baseAddress,
RequestDefinition definition,
ParameterCollection? parameters,
CancellationToken cancellationToken,
Dictionary<string, string>? additionalHeaders = null,
int? weight = null)
{
var requestId = ExchangeHelpers.NextId();
var requestWeight = weight ?? definition.Weight;
// Time sync
@@ -225,21 +302,21 @@ namespace CryptoExchange.Net.Clients
if (ClientOptions.RateLimiterEnabled)
{
var limitResult = await definition.RateLimitGate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, definition, baseAddress, ApiOptions.ApiCredentials?.Key ?? ClientOptions.ApiCredentials?.Key, requestWeight, ClientOptions.RateLimitingBehaviour, cancellationToken).ConfigureAwait(false);
var limitResult = await definition.RateLimitGate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, requestWeight, ClientOptions.RateLimitingBehaviour, cancellationToken).ConfigureAwait(false);
if (!limitResult)
return new CallResult(limitResult.Error!);
}
}
// Endpoint specific rate limiting
if (definition.EndpointLimitCount != null && definition.EndpointLimitPeriod != null)
if (definition.LimitGuard != null && ClientOptions.RateLimiterEnabled)
{
if (definition.RateLimitGate == null)
throw new Exception("Ratelimit gate not set when endpoint limit is specified");
if (ClientOptions.RateLimiterEnabled)
{
var limitResult = await definition.RateLimitGate.ProcessSingleAsync(_logger, requestId, RateLimitItemType.Request, definition, baseAddress, ApiOptions.ApiCredentials?.Key ?? ClientOptions.ApiCredentials?.Key, requestWeight, ClientOptions.RateLimitingBehaviour, cancellationToken).ConfigureAwait(false);
var limitResult = await definition.RateLimitGate.ProcessSingleAsync(_logger, requestId, definition.LimitGuard, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, ClientOptions.RateLimitingBehaviour, cancellationToken).ConfigureAwait(false);
if (!limitResult)
return new CallResult(limitResult.Error!);
}
@@ -251,40 +328,30 @@ namespace CryptoExchange.Net.Clients
/// <summary>
/// Creates a request object
/// </summary>
/// <param name="requestId">Id of the request</param>
/// <param name="baseAddress">Host and schema</param>
/// <param name="definition">Request definition</param>
/// <param name="parameters">The parameters of the request</param>
/// <param name="uriParameters">The query parameters of the request</param>
/// <param name="bodyParameters">The body parameters of the request</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <returns></returns>
protected virtual IRequest CreateRequest(
int requestId,
string baseAddress,
RequestDefinition definition,
ParameterCollection? parameters,
ParameterCollection? uriParameters,
ParameterCollection? bodyParameters,
Dictionary<string, string>? additionalHeaders)
{
parameters ??= new ParameterCollection();
var uriParams = uriParameters == null ? new ParameterCollection() : CreateParameterDictionary(uriParameters);
var bodyParams = bodyParameters == null ? new ParameterCollection() : CreateParameterDictionary(bodyParameters);
var uri = new Uri(baseAddress.AppendPath(definition.Path));
var parameterPosition = definition.ParameterPosition ?? ParameterPositions[definition.Method];
var arraySerialization = definition.ArraySerialization ?? ArraySerialization;
var bodyFormat = definition.RequestBodyFormat ?? RequestBodyFormat;
var requestId = ExchangeHelpers.NextId();
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 parameterPosition = definition.ParameterPosition ?? ParameterPositions[definition.Method];
var headers = new Dictionary<string, string>();
var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary<string, object>(parameters) : new SortedDictionary<string, object>();
if (AuthenticationProvider != null)
{
try
@@ -293,14 +360,14 @@ namespace CryptoExchange.Net.Clients
this,
uri,
definition.Method,
parameters,
uriParams,
bodyParams,
headers,
definition.Authenticated,
arraySerialization,
parameterPosition,
bodyFormat,
out uriParameters,
out bodyParameters,
out headers);
bodyFormat
);
}
catch (Exception ex)
{
@@ -308,18 +375,8 @@ namespace CryptoExchange.Net.Clients
}
}
// 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);
uri = uri.SetParameters(uriParams, arraySerialization);
var request = RequestFactory.Create(definition.Method, uri, requestId);
request.Accept = Constants.JsonContentHeader;
@@ -346,8 +403,8 @@ namespace CryptoExchange.Net.Clients
if (parameterPosition == HttpMethodParameterPosition.InBody)
{
var contentType = bodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
if (bodyParameters.Any())
WriteParamBody(request, bodyParameters, contentType);
if (bodyParams.Count != 0)
WriteParamBody(request, bodyParams, contentType);
else
request.SetContent(RequestBodyEmptyContent, contentType);
}
@@ -420,6 +477,7 @@ namespace CryptoExchange.Net.Clients
/// <param name="requestWeight">Credits used for the request</param>
/// <param name="additionalHeaders">Additional headers to send with the request</param>
/// <param name="gate">The ratelimit gate to use</param>
/// <param name="preventCaching">Whether caching should be prevented for this request</param>
/// <returns></returns>
[return: NotNull]
protected virtual async Task<WebCallResult<T>> SendRequestAsync<T>(
@@ -433,9 +491,25 @@ namespace CryptoExchange.Net.Clients
ArrayParametersSerialization? arraySerialization = null,
int requestWeight = 1,
Dictionary<string, string>? additionalHeaders = null,
IRateLimitGate? gate = null
IRateLimitGate? gate = null,
bool preventCaching = false
) where T : class
{
var key = uri.ToString() + method + signed + parameters?.ToFormData();
if (ShouldCache(method) && !preventCaching)
{
_logger.CheckingCache(key);
var cachedValue = _cache.Get(key, ClientOptions.CachingMaxAge);
if (cachedValue != null)
{
_logger.CacheHit(key);
var original = (WebCallResult<T>)cachedValue;
return original.Cached();
}
_logger.CacheNotHit(key);
}
int currentTry = 0;
while (true)
{
@@ -453,6 +527,13 @@ namespace CryptoExchange.Net.Clients
if (await ShouldRetryRequestAsync(gate, result, currentTry).ConfigureAwait(false))
continue;
if (result.Success &&
ShouldCache(method) &&
!preventCaching)
{
_cache.Add(key, result);
}
return result;
}
}
@@ -517,7 +598,7 @@ namespace CryptoExchange.Net.Clients
if (ClientOptions.RateLimiterEnabled)
{
var limitResult = await gate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, new RequestDefinition(uri.AbsolutePath.TrimStart('/'), method) { Authenticated = signed }, uri.Host, ApiOptions.ApiCredentials?.Key ?? ClientOptions.ApiCredentials?.Key, requestWeight, ClientOptions.RateLimitingBehaviour, cancellationToken).ConfigureAwait(false);
var limitResult = await gate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, new RequestDefinition(uri.AbsolutePath.TrimStart('/'), method) { Authenticated = signed }, uri.Host, AuthenticationProvider?._credentials.Key, requestWeight, ClientOptions.RateLimitingBehaviour, cancellationToken).ConfigureAwait(false);
if (!limitResult)
return new CallResult<IRequest>(limitResult.Error!);
}
@@ -592,47 +673,47 @@ namespace CryptoExchange.Net.Clients
if (error.Code == null || error.Code == 0)
error.Code = (int)response.StatusCode;
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!);
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error!);
}
if (typeof(T) == typeof(object))
// Success status code and expected empty response, assume it's correct
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, 0, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, null);
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, 0, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, null);
var valid = await accessor.Read(responseStream, outputOriginalData).ConfigureAwait(false);
if (!valid)
{
// Invalid json
var error = new ServerError("Failed to parse response", accessor.OriginalDataAvailable ? accessor.GetOriginalString() : "[Data only available when OutputOriginal = true in client options]");
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error);
var error = new ServerError("Failed to parse response: " + valid.Error!.Message, accessor.OriginalDataAvailable ? accessor.GetOriginalString() : "[Data only available when OutputOriginal = true in client options]");
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
}
// Json response received
var parsedError = TryParseError(accessor);
if (parsedError != null)
// Success status code, but TryParseError determined it was an error response
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parsedError);
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, parsedError);
var deserializeResult = accessor.Deserialize<T>();
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), deserializeResult.Data, deserializeResult.Error);
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult.Data, deserializeResult.Error);
}
catch (HttpRequestException requestException)
{
// Request exception, can't reach server for instance
var exceptionInfo = requestException.ToLogString();
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError(exceptionInfo));
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new WebError(exceptionInfo));
}
catch (OperationCanceledException canceledException)
{
if (cancellationToken != default && canceledException.CancellationToken == cancellationToken)
{
// Cancellation token canceled by caller
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new CancellationRequestedError());
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new CancellationRequestedError());
}
else
{
// Request timed out
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError($"Request timed out"));
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new WebError($"Request timed out"));
}
}
finally
@@ -726,8 +807,8 @@ namespace CryptoExchange.Net.Clients
}
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>();
var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? CreateParameterDictionary(parameters) : new Dictionary<string, object>();
var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? CreateParameterDictionary(parameters) : new Dictionary<string, object>();
if (AuthenticationProvider != null)
{
try
@@ -736,14 +817,14 @@ namespace CryptoExchange.Net.Clients
this,
uri,
method,
parameters,
uriParameters,
bodyParameters,
headers,
signed,
arraySerialization,
parameterPosition,
bodyFormat,
out uriParameters,
out bodyParameters,
out headers);
bodyFormat
);
}
catch (Exception ex)
{
@@ -804,12 +885,16 @@ namespace CryptoExchange.Net.Clients
/// <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)
protected virtual void WriteParamBody(IRequest request, IDictionary<string, object> parameters, string contentType)
{
if (contentType == Constants.JsonContentHeader)
{
// Write the parameters as json in the body
var stringData = CreateSerializer().Serialize(parameters);
string stringData;
if (parameters.Count == 1 && parameters.ContainsKey(Constants.BodyPlaceHolderKey))
stringData = CreateSerializer().Serialize(parameters[Constants.BodyPlaceHolderKey]);
else
stringData = CreateSerializer().Serialize(parameters);
request.SetContent(stringData, contentType);
}
else if (contentType == Constants.FormContentHeader)
@@ -859,6 +944,19 @@ namespace CryptoExchange.Net.Clients
return new ServerRateLimitError(message);
}
/// <summary>
/// Create the parameter IDictionary
/// </summary>
/// <param name="parameters"></param>
/// <returns></returns>
protected internal IDictionary<string, object> CreateParameterDictionary(IDictionary<string, object> parameters)
{
if (!OrderParameters)
return parameters;
return new SortedDictionary<string, object>(parameters, ParameterOrderComparer);
}
/// <summary>
/// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues
/// </summary>
@@ -869,14 +967,14 @@ namespace CryptoExchange.Net.Clients
{
var timeSyncParams = GetTimeSyncInfo();
if (timeSyncParams == null)
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, true, null);
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null);
if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false))
{
if (!timeSyncParams.SyncTime || DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval)
{
timeSyncParams.TimeSyncState.Semaphore.Release();
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, true, null);
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null);
}
var localTime = DateTime.UtcNow;
@@ -905,7 +1003,16 @@ namespace CryptoExchange.Net.Clients
timeSyncParams.TimeSyncState.Semaphore.Release();
}
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, true, null);
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null);
}
private bool ShouldCache(RequestDefinition definition)
=> ClientOptions.CachingEnabled
&& definition.Method == HttpMethod.Get
&& !definition.PreventCaching;
private bool ShouldCache(HttpMethod method)
=> ClientOptions.CachingEnabled
&& method == HttpMethod.Get;
}
}
+115 -43
View File
@@ -8,6 +8,7 @@ using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.Sockets;
using Microsoft.Extensions.Logging;
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
@@ -52,11 +53,6 @@ namespace CryptoExchange.Net.Clients
/// </summary>
protected internal bool UnhandledMessageExpected { get; set; }
/// <summary>
/// If true a subscription will accept message before the confirmation of a subscription has been received
/// </summary>
protected bool HandleMessageBeforeConfirmation { get; set; }
/// <summary>
/// The rate limiters
/// </summary>
@@ -72,6 +68,11 @@ namespace CryptoExchange.Net.Clients
/// </summary>
protected List<PeriodicTaskRegistration> PeriodicTaskRegistrations { get; set; } = new List<PeriodicTaskRegistration>();
/// <summary>
/// List of address to keep an alive connection to
/// </summary>
protected List<DedicatedConnectionConfig> DedicatedConnectionConfigs { get; set; } = new List<DedicatedConnectionConfig>();
/// <inheritdoc />
public double IncomingKbps
{
@@ -136,6 +137,16 @@ namespace CryptoExchange.Net.Clients
/// <returns></returns>
protected internal virtual IMessageSerializer CreateSerializer() => new JsonNetMessageSerializer();
/// <summary>
/// Keep an open connection to this url
/// </summary>
/// <param name="url"></param>
/// <param name="auth"></param>
protected virtual void SetDedicatedConnection(string url, bool auth)
{
DedicatedConnectionConfigs.Add(new DedicatedConnectionConfig() { SocketAddress = url, Authenticated = auth });
}
/// <summary>
/// Add a query to periodically send on each connection
/// </summary>
@@ -178,7 +189,10 @@ namespace CryptoExchange.Net.Clients
return new CallResult<UpdateSubscription>(new InvalidOperationError("Client disposed, can't subscribe"));
if (subscription.Authenticated && AuthenticationProvider == null)
{
_logger.LogWarning("Failed to subscribe, private subscription but no API credentials set");
return new CallResult<UpdateSubscription>(new NoApiCredentialsError());
}
SocketConnection socketConnection;
var released = false;
@@ -198,12 +212,11 @@ namespace CryptoExchange.Net.Clients
while (true)
{
// Get a new or existing socket connection
var socketResult = await GetSocketConnection(url, subscription.Authenticated).ConfigureAwait(false);
var socketResult = await GetSocketConnection(url, subscription.Authenticated, false).ConfigureAwait(false);
if (!socketResult)
return socketResult.As<UpdateSubscription>(null);
socketConnection = socketResult.Data;
subscription.HandleUpdatesBeforeConfirmation = subscription.HandleUpdatesBeforeConfirmation || HandleMessageBeforeConfirmation;
// Add a subscription on the socket connection
var success = socketConnection.AddSubscription(subscription);
@@ -241,7 +254,7 @@ namespace CryptoExchange.Net.Clients
return new CallResult<UpdateSubscription>(new ServerError("Socket is paused"));
}
var waitEvent = new ManualResetEvent(false);
var waitEvent = new AsyncResetEvent(false);
var subQuery = subscription.GetSubQuery(socketConnection);
if (subQuery != null)
{
@@ -250,11 +263,18 @@ namespace CryptoExchange.Net.Clients
if (!subResult)
{
waitEvent?.Set();
_logger.FailedToSubscribe(socketConnection.SocketId, subResult.Error?.ToString());
// If this was a timeout we still need to send an unsubscribe to prevent messages coming in later
var unsubscribe = subResult.Error is CancellationRequestedError;
await socketConnection.CloseAsync(subscription, unsubscribe).ConfigureAwait(false);
return new CallResult<UpdateSubscription>(subResult.Error!);
var isTimeout = subResult.Error is CancellationRequestedError;
if (isTimeout && subscription.Confirmed)
{
// No response received, but the subscription did receive updates. We'll assume success
}
else
{
_logger.FailedToSubscribe(socketConnection.SocketId, subResult.Error?.ToString());
// If this was a timeout we still need to send an unsubscribe to prevent messages coming in later
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
return new CallResult<UpdateSubscription>(subResult.Error!);
}
}
subscription.HandleSubQueryResponse(subQuery.Response!);
@@ -278,34 +298,41 @@ namespace CryptoExchange.Net.Clients
/// <summary>
/// Send a query on a socket connection to the BaseAddress and wait for the response
/// </summary>
/// <typeparam name="T">Expected result type</typeparam>
/// <typeparam name="THandlerResponse">Expected result type</typeparam>
/// <typeparam name="TServerResponse">The type returned to the caller</typeparam>
/// <param name="query">The query</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
protected virtual Task<CallResult<T>> QueryAsync<T>(Query<T> query)
protected virtual Task<CallResult<THandlerResponse>> QueryAsync<TServerResponse, THandlerResponse>(Query<TServerResponse, THandlerResponse> query, CancellationToken ct = default)
{
return QueryAsync(BaseAddress, query);
return QueryAsync(BaseAddress, query, ct);
}
/// <summary>
/// Send a query on a socket connection and wait for the response
/// </summary>
/// <typeparam name="T">The expected result type</typeparam>
/// <typeparam name="THandlerResponse">Expected result type</typeparam>
/// <typeparam name="TServerResponse">The type returned to the caller</typeparam>
/// <param name="url">The url for the request</param>
/// <param name="query">The query</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
protected virtual async Task<CallResult<T>> QueryAsync<T>(string url, Query<T> query)
protected virtual async Task<CallResult<THandlerResponse>> QueryAsync<TServerResponse, THandlerResponse>(string url, Query<TServerResponse, THandlerResponse> query, CancellationToken ct = default)
{
if (_disposing)
return new CallResult<T>(new InvalidOperationError("Client disposed, can't query"));
return new CallResult<THandlerResponse>(new InvalidOperationError("Client disposed, can't query"));
if (ct.IsCancellationRequested)
return new CallResult<THandlerResponse>(new CancellationRequestedError());
SocketConnection socketConnection;
var released = false;
await semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
var socketResult = await GetSocketConnection(url, query.Authenticated).ConfigureAwait(false);
var socketResult = await GetSocketConnection(url, query.Authenticated, true).ConfigureAwait(false);
if (!socketResult)
return socketResult.As<T>(default);
return socketResult.As<THandlerResponse>(default);
socketConnection = socketResult.Data;
@@ -318,7 +345,7 @@ namespace CryptoExchange.Net.Clients
var connectResult = await ConnectIfNeededAsync(socketConnection, query.Authenticated).ConfigureAwait(false);
if (!connectResult)
return new CallResult<T>(connectResult.Error!);
return new CallResult<THandlerResponse>(connectResult.Error!);
}
finally
{
@@ -329,10 +356,13 @@ namespace CryptoExchange.Net.Clients
if (socketConnection.PausedActivity)
{
_logger.HasBeenPausedCantSendQueryAtThisMoment(socketConnection.SocketId);
return new CallResult<T>(new ServerError("Socket is paused"));
return new CallResult<THandlerResponse>(new ServerError("Socket is paused"));
}
return await socketConnection.SendAndWaitQueryAsync(query).ConfigureAwait(false);
if (ct.IsCancellationRequested)
return new CallResult<THandlerResponse>(new CancellationRequestedError());
return await socketConnection.SendAndWaitQueryAsync(query, null, ct).ConfigureAwait(false);
}
/// <summary>
@@ -356,7 +386,11 @@ namespace CryptoExchange.Net.Clients
if (!authenticated || socket.Authenticated)
return new CallResult(null);
return await AuthenticateSocketAsync(socket).ConfigureAwait(false);
var result = await AuthenticateSocketAsync(socket).ConfigureAwait(false);
if (!result)
await socket.CloseAsync().ConfigureAwait(false);
return result;
}
/// <summary>
@@ -370,7 +404,7 @@ namespace CryptoExchange.Net.Clients
return new CallResult(new NoApiCredentialsError());
_logger.AttemptingToAuthenticate(socket.SocketId);
var authRequest = GetAuthenticationRequest();
var authRequest = GetAuthenticationRequest(socket);
if (authRequest != null)
{
var result = await socket.SendAndWaitQueryAsync(authRequest).ConfigureAwait(false);
@@ -395,7 +429,7 @@ namespace CryptoExchange.Net.Clients
/// Should return the request which can be used to authenticate a socket connection
/// </summary>
/// <returns></returns>
protected internal virtual Query? GetAuthenticationRequest() => throw new NotImplementedException();
protected internal virtual Query? GetAuthenticationRequest(SocketConnection connection) => throw new NotImplementedException();
/// <summary>
/// Adds a system subscription. Used for example to reply to ping requests
@@ -444,19 +478,31 @@ namespace CryptoExchange.Net.Clients
/// </summary>
/// <param name="address">The address the socket is for</param>
/// <param name="authenticated">Whether the socket should be authenticated</param>
/// <param name="dedicatedRequestConnection">Whether a dedicated request connection should be returned</param>
/// <returns></returns>
protected virtual async Task<CallResult<SocketConnection>> GetSocketConnection(string address, bool authenticated)
protected virtual async Task<CallResult<SocketConnection>> GetSocketConnection(string address, bool authenticated, bool dedicatedRequestConnection)
{
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.UserSubscriptionCount).FirstOrDefault();
var result = socketResult.Equals(default(KeyValuePair<int, SocketConnection>)) ? null : socketResult.Value;
if (result != null)
var socketQuery = 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);
SocketConnection connection;
if (!dedicatedRequestConnection)
{
if (result.UserSubscriptionCount < ClientOptions.SocketSubscriptionsCombineTarget || socketConnections.Count >= (ApiOptions.MaxSocketConnections ?? ClientOptions.MaxSocketConnections) && socketConnections.All(s => s.Value.UserSubscriptionCount >= ClientOptions.SocketSubscriptionsCombineTarget))
connection = socketQuery.Where(s => !s.Value.DedicatedRequestConnection).OrderBy(s => s.Value.UserSubscriptionCount).FirstOrDefault().Value;
}
else
{
connection = socketQuery.Where(s => s.Value.DedicatedRequestConnection).FirstOrDefault().Value;
}
if (connection != null)
{
if (connection.UserSubscriptionCount < ClientOptions.SocketSubscriptionsCombineTarget || socketConnections.Count >= (ApiOptions.MaxSocketConnections ?? ClientOptions.MaxSocketConnections) && socketConnections.All(s => s.Value.UserSubscriptionCount >= 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);
return new CallResult<SocketConnection>(connection);
}
var connectionAddress = await GetConnectionUrlAsync(address, authenticated).ConfigureAwait(false);
@@ -473,6 +519,7 @@ namespace CryptoExchange.Net.Clients
var socket = CreateSocket(connectionAddress.Data!);
var socketConnection = new SocketConnection(_logger, this, socket, address);
socketConnection.UnhandledMessage += HandleUnhandledMessage;
socketConnection.DedicatedRequestConnection = dedicatedRequestConnection;
foreach (var ptg in PeriodicTaskRegistrations)
socketConnection.QueryPeriodic(ptg.Identifier, ptg.Interval, ptg.QueryDelegate, ptg.Callback);
@@ -515,7 +562,7 @@ namespace CryptoExchange.Net.Clients
/// <param name="address">The address to connect to</param>
/// <returns></returns>
protected virtual WebSocketParameters GetWebSocketParameters(string address)
=> new(new Uri(address), ClientOptions.AutoReconnect)
=> new(new Uri(address), ClientOptions.ReconnectPolicy)
{
KeepAliveInterval = KeepAliveInterval,
ReconnectInterval = ClientOptions.ReconnectInterval,
@@ -592,8 +639,8 @@ namespace CryptoExchange.Net.Clients
var tasks = new List<Task>();
{
var socketList = socketConnections.Values;
foreach (var sub in socketList)
tasks.Add(sub.CloseAsync());
foreach (var connection in socketList.Where(s => !s.DedicatedRequestConnection))
tasks.Add(connection.CloseAsync());
}
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
@@ -616,6 +663,23 @@ namespace CryptoExchange.Net.Clients
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
}
/// <inheritdoc />
public virtual async Task<CallResult> PrepareConnectionsAsync()
{
foreach (var item in DedicatedConnectionConfigs)
{
var socketResult = await GetSocketConnection(item.SocketAddress, item.Authenticated, true).ConfigureAwait(false);
if (!socketResult)
return socketResult.AsDataless();
var connectResult = await ConnectIfNeededAsync(socketResult.Data, item.Authenticated).ConfigureAwait(false);
if (!connectResult)
return new CallResult(connectResult.Error!);
}
return new CallResult(null);
}
/// <summary>
/// Log the current state of connections and subscriptions
/// </summary>
@@ -699,11 +763,18 @@ namespace CryptoExchange.Net.Clients
public override void Dispose()
{
_disposing = true;
if (socketConnections.Sum(s => s.Value.UserSubscriptionCount) > 0)
var tasks = new List<Task>();
{
_logger.DisposingSocketClient();
_ = UnsubscribeAllAsync();
var socketList = socketConnections.Values.Where(x => x.UserSubscriptionCount > 0 || x.Connected);
if (socketList.Any())
_logger.DisposingSocketClient();
foreach (var connection in socketList)
{
tasks.Add(connection.CloseAsync());
}
}
semaphoreSlim?.Dispose();
base.Dispose();
}
@@ -718,9 +789,10 @@ namespace CryptoExchange.Net.Clients
/// <summary>
/// Preprocess a stream message
/// </summary>
/// <param name="connection"></param>
/// <param name="type"></param>
/// <param name="data"></param>
/// <returns></returns>
public virtual ReadOnlyMemory<byte> PreprocessStreamMessage(WebSocketMessageType type, ReadOnlyMemory<byte> data) => data;
public virtual ReadOnlyMemory<byte> PreprocessStreamMessage(SocketConnection connection, WebSocketMessageType type, ReadOnlyMemory<byte> data) => data;
}
}
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -38,14 +39,8 @@ namespace CryptoExchange.Net.Converters.JsonNet
var longValue = (long)reader.Value;
if (longValue == 0 || longValue == -1)
return objectType == typeof(DateTime) ? default(DateTime): null;
if (longValue < 19999999999)
return ConvertFromSeconds(longValue);
if (longValue < 19999999999999)
return ConvertFromMilliseconds(longValue);
if (longValue < 19999999999999999)
return ConvertFromMicroseconds(longValue);
return ConvertFromNanoseconds(longValue);
return ParseFromLong(longValue);
}
else if (reader.TokenType is JsonToken.Float)
{
@@ -68,76 +63,7 @@ namespace CryptoExchange.Net.Converters.JsonNet
return objectType == typeof(DateTime) ? default(DateTime) : null;
}
if (stringValue.Length == 12 && stringValue.StartsWith("202"))
{
// Parse 202303261200 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day)
|| !int.TryParse(stringValue.Substring(8, 2), out var hour)
|| !int.TryParse(stringValue.Substring(10, 2), out var minute))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
return default;
}
return new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 8)
{
// Parse 20211103 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 6)
{
// Parse 211103 format
if (!int.TryParse(stringValue.Substring(0, 2), out var year)
|| !int.TryParse(stringValue.Substring(2, 2), out var month)
|| !int.TryParse(stringValue.Substring(4, 2), out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
return default;
}
return new DateTime(year + 2000, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
// Parse 1637745563.000 format
if (doubleValue < 19999999999)
return ConvertFromSeconds(doubleValue);
if (doubleValue < 19999999999999)
return ConvertFromMilliseconds((long)doubleValue);
if (doubleValue < 19999999999999999)
return ConvertFromMicroseconds((long)doubleValue);
return ConvertFromNanoseconds((long)doubleValue);
}
if(stringValue.Length == 10)
{
// Parse 2021-11-03 format
var values = stringValue.Split('-');
if(!int.TryParse(values[0], out var year)
|| !int.TryParse(values[1], out var month)
|| !int.TryParse(values[2], out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + reader.Value);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
return DateTime.Parse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
return ParseFromString(stringValue);
}
else if(reader.TokenType == JsonToken.Date)
{
@@ -150,6 +76,102 @@ namespace CryptoExchange.Net.Converters.JsonNet
}
}
/// <summary>
/// Parse a long value to datetime
/// </summary>
/// <param name="longValue"></param>
/// <returns></returns>
public static DateTime ParseFromLong(long longValue)
{
if (longValue < 19999999999)
return ConvertFromSeconds(longValue);
if (longValue < 19999999999999)
return ConvertFromMilliseconds(longValue);
if (longValue < 19999999999999999)
return ConvertFromMicroseconds(longValue);
return ConvertFromNanoseconds(longValue);
}
/// <summary>
/// Parse a string value to datetime
/// </summary>
/// <param name="stringValue"></param>
/// <returns></returns>
public static DateTime ParseFromString(string stringValue)
{
if (stringValue.Length == 12 && stringValue.StartsWith("202"))
{
// Parse 202303261200 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day)
|| !int.TryParse(stringValue.Substring(8, 2), out var hour)
|| !int.TryParse(stringValue.Substring(10, 2), out var minute))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 8)
{
// Parse 20211103 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 6)
{
// Parse 211103 format
if (!int.TryParse(stringValue.Substring(0, 2), out var year)
|| !int.TryParse(stringValue.Substring(2, 2), out var month)
|| !int.TryParse(stringValue.Substring(4, 2), out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year + 2000, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
// Parse 1637745563.000 format
if (doubleValue < 19999999999)
return ConvertFromSeconds(doubleValue);
if (doubleValue < 19999999999999)
return ConvertFromMilliseconds((long)doubleValue);
if (doubleValue < 19999999999999999)
return ConvertFromMicroseconds((long)doubleValue);
return ConvertFromNanoseconds((long)doubleValue);
}
if (stringValue.Length == 10)
{
// Parse 2021-11-03 format
var values = stringValue.Split('-');
if (!int.TryParse(values[0], out var year)
|| !int.TryParse(values[1], out var month)
|| !int.TryParse(values[2], out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
return DateTime.Parse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
}
/// <summary>
/// Convert a seconds since epoch (01-01-1970) value to DateTime
/// </summary>
@@ -224,7 +224,7 @@ namespace CryptoExchange.Net.Converters.JsonNet
public override bool OriginalDataAvailable => _stream?.CanSeek == true;
/// <inheritdoc />
public async Task<bool> Read(Stream stream, bool bufferStream)
public async Task<CallResult> Read(Stream stream, bool bufferStream)
{
if (bufferStream && stream is not MemoryStream)
{
@@ -252,14 +252,15 @@ namespace CryptoExchange.Net.Converters.JsonNet
{
_token = await JToken.LoadAsync(jsonTextReader).ConfigureAwait(false);
IsJson = true;
return new CallResult(null);
}
catch (Exception)
catch (Exception ex)
{
// Not a json message
IsJson = false;
return new CallResult(new ServerError("JsonError: " + ex.Message));
}
return IsJson;
}
/// <inheritdoc />
public override string GetOriginalString()
@@ -290,7 +291,7 @@ namespace CryptoExchange.Net.Converters.JsonNet
private ReadOnlyMemory<byte> _bytes;
/// <inheritdoc />
public bool Read(ReadOnlyMemory<byte> data)
public CallResult Read(ReadOnlyMemory<byte> data)
{
_bytes = data;
@@ -305,14 +306,14 @@ namespace CryptoExchange.Net.Converters.JsonNet
{
_token = JToken.Load(jsonTextReader);
IsJson = true;
return new CallResult(null);
}
catch (Exception)
catch (Exception ex)
{
// Not a json message
IsJson = false;
return new CallResult(new ServerError("JsonError: " + ex.Message));
}
return IsJson;
}
/// <inheritdoc />
@@ -32,6 +32,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
public ArrayPropertyAttribute ArrayProperty { get; set; } = null!;
public Type? JsonConverterType { get; set; }
public bool DefaultDeserialization { get; set; }
public Type TargetType { get; set; } = null!;
}
private class ArrayConverterInner<T> : JsonConverter<T>
@@ -70,7 +71,8 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
ArrayProperty = att,
PropertyInfo = property,
DefaultDeserialization = property.GetCustomAttribute<JsonConversionAttribute>() != null,
JsonConverterType = property.GetCustomAttribute<JsonConverterAttribute>()?.ConverterType
JsonConverterType = property.GetCustomAttribute<JsonConverterAttribute>()?.ConverterType,
TargetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType
});
}
@@ -81,7 +83,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
private static object ParseObject(ref Utf8JsonReader reader, object result, Type objectType)
{
if (reader.TokenType != JsonTokenType.StartArray)
throw new Exception("1");
throw new Exception("Not an array");
if (!_typeAttributesCache.TryGetValue(objectType, out var attributes))
attributes = CacheTypeAttributes(objectType);
@@ -92,9 +94,14 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
if (reader.TokenType == JsonTokenType.EndArray)
break;
var attribute = attributes.SingleOrDefault(a => a.ArrayProperty.Index == index);
var targetType = attribute.PropertyInfo.PropertyType;
var attribute = attributes.SingleOrDefault(a => a.ArrayProperty.Index == index);
if (attribute == null)
{
index++;
continue;
}
var targetType = attribute.TargetType;
object? value = null;
if (attribute.JsonConverterType != null)
{
@@ -121,7 +128,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
};
}
attribute.PropertyInfo.SetValue(result, value == null ? null : Convert.ChangeType(value, attribute.PropertyInfo.PropertyType, CultureInfo.InvariantCulture));
attribute.PropertyInfo.SetValue(result, value == null ? null : Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture));
index++;
}
@@ -1,4 +1,5 @@
using System;
using Microsoft.Extensions.Primitives;
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -49,14 +50,8 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
var longValue = reader.GetDouble();
if (longValue == 0 || longValue == -1)
return default;
if (longValue < 19999999999)
return ConvertFromSeconds(longValue);
if (longValue < 19999999999999)
return ConvertFromMilliseconds(longValue);
if (longValue < 19999999999999999)
return ConvertFromMicroseconds(longValue);
return ConvertFromNanoseconds(longValue);
return ParseFromDouble(longValue);
}
else if (reader.TokenType is JsonTokenType.String)
{
@@ -68,76 +63,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
return default;
}
if (stringValue!.Length == 12 && stringValue.StartsWith("202"))
{
// Parse 202303261200 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day)
|| !int.TryParse(stringValue.Substring(8, 2), out var hour)
|| !int.TryParse(stringValue.Substring(10, 2), out var minute))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 8)
{
// Parse 20211103 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 6)
{
// Parse 211103 format
if (!int.TryParse(stringValue.Substring(0, 2), out var year)
|| !int.TryParse(stringValue.Substring(2, 2), out var month)
|| !int.TryParse(stringValue.Substring(4, 2), out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year + 2000, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
// Parse 1637745563.000 format
if (doubleValue < 19999999999)
return ConvertFromSeconds(doubleValue);
if (doubleValue < 19999999999999)
return ConvertFromMilliseconds((long)doubleValue);
if (doubleValue < 19999999999999999)
return ConvertFromMicroseconds((long)doubleValue);
return ConvertFromNanoseconds((long)doubleValue);
}
if (stringValue.Length == 10)
{
// Parse 2021-11-03 format
var values = stringValue.Split('-');
if (!int.TryParse(values[0], out var year)
|| !int.TryParse(values[1], out var month)
|| !int.TryParse(values[2], out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
return DateTime.Parse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
return ParseFromString(stringValue!);
}
else
{
@@ -160,6 +86,104 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
}
}
/// <summary>
/// Parse a long value to datetime
/// </summary>
/// <param name="longValue"></param>
/// <returns></returns>
public static DateTime ParseFromDouble(double longValue)
{
if (longValue < 19999999999)
return ConvertFromSeconds(longValue);
if (longValue < 19999999999999)
return ConvertFromMilliseconds(longValue);
if (longValue < 19999999999999999)
return ConvertFromMicroseconds(longValue);
return ConvertFromNanoseconds(longValue);
}
/// <summary>
/// Parse a string value to datetime
/// </summary>
/// <param name="stringValue"></param>
/// <returns></returns>
public static DateTime ParseFromString(string stringValue)
{
if (stringValue!.Length == 12 && stringValue.StartsWith("202"))
{
// Parse 202303261200 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day)
|| !int.TryParse(stringValue.Substring(8, 2), out var hour)
|| !int.TryParse(stringValue.Substring(10, 2), out var minute))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 8)
{
// Parse 20211103 format
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|| !int.TryParse(stringValue.Substring(6, 2), out var day))
{
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (stringValue.Length == 6)
{
// Parse 211103 format
if (!int.TryParse(stringValue.Substring(0, 2), out var year)
|| !int.TryParse(stringValue.Substring(2, 2), out var month)
|| !int.TryParse(stringValue.Substring(4, 2), out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year + 2000, month, day, 0, 0, 0, DateTimeKind.Utc);
}
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
// Parse 1637745563.000 format
if (doubleValue <= 0)
return default;
if (doubleValue < 19999999999)
return ConvertFromSeconds(doubleValue);
if (doubleValue < 19999999999999)
return ConvertFromMilliseconds((long)doubleValue);
if (doubleValue < 19999999999999999)
return ConvertFromMicroseconds((long)doubleValue);
return ConvertFromNanoseconds((long)doubleValue);
}
if (stringValue.Length == 10)
{
// Parse 2021-11-03 format
var values = stringValue.Split('-');
if (!int.TryParse(values[0], out var year)
|| !int.TryParse(values[1], out var month)
|| !int.TryParse(values[2], out var day))
{
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
return default;
}
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
}
return DateTime.Parse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
}
/// <summary>
/// Convert a seconds since epoch (01-01-1970) value to DateTime
/// </summary>
@@ -0,0 +1,40 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
/// Int converter
/// </summary>
public class IntConverter : JsonConverter<int?>
{
/// <inheritdoc />
public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var value = reader.GetString();
if (string.IsNullOrEmpty(value))
return null;
return int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
}
return reader.GetInt32();
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options)
{
if (value == null)
writer.WriteNullValue();
else
writer.WriteNumberValue(value.Value);
}
}
}
@@ -0,0 +1,40 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
/// Int converter
/// </summary>
public class LongConverter : JsonConverter<long?>
{
/// <inheritdoc />
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var value = reader.GetString();
if (string.IsNullOrEmpty(value))
return null;
return long.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
}
return reader.GetInt64();
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value == null)
writer.WriteNullValue();
else
writer.WriteNumberValue(value.Value);
}
}
}
@@ -0,0 +1,31 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
/// Read string or number as string
/// </summary>
public class NumberStringConverter : JsonConverter<string?>
{
/// <inheritdoc />
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.Number)
return reader.GetInt64().ToString();
return reader.GetString();
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
}
}
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.Serialization;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Globalization;
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
///
/// </summary>
/// <typeparam name="T"></typeparam>
public class ObjectStringConverter<T> : JsonConverter<T>
{
/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return default;
var value = reader.GetString();
if (string.IsNullOrEmpty(value))
return default;
return (T?)JsonDocument.Parse(value!).Deserialize(typeof(T));
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
if (value is null)
writer.WriteStringValue("");
writer.WriteStringValue(JsonSerializer.Serialize(value, options));
}
}
}
@@ -21,6 +21,8 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
new EnumConverter(),
new BoolConverter(),
new DecimalConverter(),
new IntConverter(),
new LongConverter()
}
};
}
@@ -68,6 +68,11 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
var info = $"Deserialize JsonException: {ex.Message}, Path: {ex.Path}, LineNumber: {ex.LineNumber}, LinePosition: {ex.BytePositionInLine}";
return new CallResult<T>(new DeserializeError(info, OriginalDataAvailable ? GetOriginalString() : "[Data only available when OutputOriginal = true in client options]"));
}
catch (Exception ex)
{
var info = $"Unknown exception: {ex.Message}";
return new CallResult<T>(new DeserializeError(info, OriginalDataAvailable ? GetOriginalString() : "[Data only available when OutputOriginal = true in client options]"));
}
}
/// <inheritdoc />
@@ -188,7 +193,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
public override bool OriginalDataAvailable => _stream?.CanSeek == true;
/// <inheritdoc />
public async Task<bool> Read(Stream stream, bool bufferStream)
public async Task<CallResult> Read(Stream stream, bool bufferStream)
{
if (bufferStream && stream is not MemoryStream)
{
@@ -211,15 +216,16 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
{
_document = await JsonDocument.ParseAsync(_stream ?? stream).ConfigureAwait(false);
IsJson = true;
return new CallResult(null);
}
catch (Exception)
catch (Exception ex)
{
// Not a json message
IsJson = false;
return new CallResult(new ServerError("JsonError: " + ex.Message));
}
return IsJson;
}
/// <inheritdoc />
public override string GetOriginalString()
{
@@ -236,6 +242,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
{
_stream?.Dispose();
_stream = null;
_document?.Dispose();
_document = null;
}
@@ -249,22 +256,30 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
private ReadOnlyMemory<byte> _bytes;
/// <inheritdoc />
public bool Read(ReadOnlyMemory<byte> data)
public CallResult Read(ReadOnlyMemory<byte> data)
{
_bytes = data;
try
{
var firstByte = data.Span[0];
if (firstByte != 0x7b && firstByte != 0x5b)
{
// Value doesn't start with `{` or `[`, prevent deserialization attempt as it's slow
IsJson = false;
return new CallResult(new ServerError("Not a json value"));
}
_document = JsonDocument.Parse(data);
IsJson = true;
return new CallResult(null);
}
catch (Exception)
catch (Exception ex)
{
// Not a json message
IsJson = false;
return new CallResult(new ServerError("JsonError: " + ex.Message));
}
return IsJson;
}
/// <inheritdoc />
@@ -283,6 +298,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
public override void Clear()
{
_bytes = null;
_document?.Dispose();
_document = null;
}
}
+4 -4
View File
@@ -6,9 +6,9 @@
<PackageId>CryptoExchange.Net</PackageId>
<Authors>JKorf</Authors>
<Description>CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations.</Description>
<PackageVersion>7.3.3</PackageVersion>
<AssemblyVersion>7.3.3</AssemblyVersion>
<FileVersion>7.3.3</FileVersion>
<PackageVersion>7.10.0</PackageVersion>
<AssemblyVersion>7.10.0</AssemblyVersion>
<FileVersion>7.10.0</FileVersion>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageTags>OKX;OKX.Net;Mexc;Mexc.Net;Kucoin;Kucoin.Net;Kraken;Kraken.Net;Huobi;Huobi.Net;CoinEx;CoinEx.Net;Bybit;Bybit.Net;Bitget;Bitget.Net;Bitfinex;Bitfinex.Net;Binance;Binance.Net;CryptoCurrency;CryptoCurrency Exchange</PackageTags>
<RepositoryType>git</RepositoryType>
@@ -58,6 +58,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.3" />
<PackageReference Include="System.Text.Json" Version="8.0.4" />
</ItemGroup>
</Project>
+22 -2
View File
@@ -96,6 +96,9 @@ namespace CryptoExchange.Net
var formData = HttpUtility.ParseQueryString(string.Empty);
foreach (var kvp in parameters)
{
if (kvp.Value is null)
continue;
if (kvp.Value.GetType().IsArray)
{
var array = (Array)kvp.Value;
@@ -342,7 +345,7 @@ namespace CryptoExchange.Net
/// <param name="baseUri"></param>
/// <param name="arraySerialization"></param>
/// <returns></returns>
public static Uri SetParameters(this Uri baseUri, SortedDictionary<string, object> parameters, ArrayParametersSerialization arraySerialization)
public static Uri SetParameters(this Uri baseUri, IDictionary<string, object> parameters, ArrayParametersSerialization arraySerialization)
{
var uriBuilder = new UriBuilder();
uriBuilder.Scheme = baseUri.Scheme;
@@ -450,7 +453,7 @@ namespace CryptoExchange.Net
}
/// <summary>
/// Decompress using Gzip
/// Decompress using GzipStream
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
@@ -464,6 +467,23 @@ namespace CryptoExchange.Net
deflateStream.CopyTo(decompressedStream);
return new ReadOnlyMemory<byte>(decompressedStream.GetBuffer(), 0, (int)decompressedStream.Length);
}
/// <summary>
/// Decompress using DeflateStream
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public static ReadOnlyMemory<byte> Decompress(this ReadOnlyMemory<byte> input)
{
var output = new MemoryStream();
using (var compressStream = new MemoryStream(input.ToArray()))
using (var decompressor = new DeflateStream(compressStream, CompressionMode.Decompress))
decompressor.CopyTo(output);
output.Position = 0;
return new ReadOnlyMemory<byte>(output.GetBuffer(), 0, (int)output.Length);
}
}
}
@@ -0,0 +1,16 @@
using System;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Time provider
/// </summary>
internal interface IAuthTimeProvider
{
/// <summary>
/// Get current time
/// </summary>
/// <returns></returns>
DateTime GetTime();
}
}
@@ -12,6 +12,14 @@ namespace CryptoExchange.Net.Interfaces
/// </summary>
string BaseAddress { get; }
/// <summary>
/// Format a base and quote asset to an exchange accepted symbol
/// </summary>
/// <param name="baseAsset">The base asset</param>
/// <param name="quoteAsset">The quote asset</param>
/// <returns></returns>
string FormatSymbol(string baseAsset, string quoteAsset);
/// <summary>
/// Set the API credentials for this API client
/// </summary>
@@ -84,7 +84,7 @@ namespace CryptoExchange.Net.Interfaces
/// </summary>
/// <param name="stream"></param>
/// <param name="bufferStream"></param>
Task<bool> Read(Stream stream, bool bufferStream);
Task<CallResult> Read(Stream stream, bool bufferStream);
}
/// <summary>
@@ -96,6 +96,6 @@ namespace CryptoExchange.Net.Interfaces
/// Load a data message
/// </summary>
/// <param name="data"></param>
bool Read(ReadOnlyMemory<byte> data);
CallResult Read(ReadOnlyMemory<byte> data);
}
}
@@ -3,6 +3,7 @@ using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.Sockets;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Interfaces
{
@@ -16,10 +17,6 @@ namespace CryptoExchange.Net.Interfaces
/// </summary>
public int Id { get; }
/// <summary>
/// Whether this listener can handle data
/// </summary>
public bool CanHandleData { get; }
/// <summary>
/// The identifiers for this processor
/// </summary>
public HashSet<string> ListenerIdentifiers { get; }
@@ -29,7 +26,7 @@ namespace CryptoExchange.Net.Interfaces
/// <param name="connection"></param>
/// <param name="message"></param>
/// <returns></returns>
CallResult Handle(SocketConnection connection, DataEvent<object> message);
Task<CallResult> Handle(SocketConnection connection, DataEvent<object> message);
/// <summary>
/// Get the type the message should be deserialized to
/// </summary>
@@ -0,0 +1,27 @@
using CryptoExchange.Net.Objects.Options;
using System;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Factory for ISymbolOrderBook instances
/// </summary>
public interface IOrderBookFactory<TOptions> where TOptions : OrderBookOptions
{
/// <summary>
/// Create a new order book by symbol name
/// </summary>
/// <param name="symbol">Symbol name</param>
/// <param name="options">Options for the order book</param>
/// <returns></returns>
public ISymbolOrderBook Create(string symbol, Action<TOptions>? options = null);
/// <summary>
/// Create a new order book by base and quote asset names
/// </summary>
/// <param name="baseAsset">Base asset name</param>
/// <param name="quoteAsset">Quote asset name</param>
/// <param name="options">Options for the order book</param>
/// <returns></returns>
public ISymbolOrderBook Create(string baseAsset, string quoteAsset, Action<TOptions>? options = null);
}
}
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Objects.Options;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Options;
using CryptoExchange.Net.Objects.Sockets;
using System.Threading.Tasks;
@@ -59,5 +60,11 @@ namespace CryptoExchange.Net.Interfaces
/// <param name="subscription">The subscription to unsubscribe</param>
/// <returns></returns>
Task UnsubscribeAsync(UpdateSubscription subscription);
/// <summary>
/// Prepare connections which can subsequently be used for sending websocket requests.
/// </summary>
/// <returns></returns>
Task<CallResult> PrepareConnectionsAsync();
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ namespace CryptoExchange.Net.Interfaces
/// <summary>
/// Websocket message received event
/// </summary>
event Action<WebSocketMessageType, ReadOnlyMemory<byte>> OnStreamMessage;
event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task> OnStreamMessage;
/// <summary>
/// Websocket sent event, RequestId as parameter
/// </summary>
@@ -78,7 +78,7 @@ namespace CryptoExchange.Net.Interfaces
/// <param name="id"></param>
/// <param name="data"></param>
/// <param name="weight"></param>
void Send(int id, string data, int weight);
bool Send(int id, string data, int weight);
/// <summary>
/// Reconnect the socket
/// </summary>
@@ -3,7 +3,8 @@ using System;
namespace CryptoExchange.Net.Logging.Extensions
{
internal static class CryptoExchangeWebSocketClientLoggingExtension
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static class CryptoExchangeWebSocketClientLoggingExtension
{
private static readonly Action<ILogger, int, Exception?> _connecting;
private static readonly Action<ILogger, int, string, Exception?> _connectionFailed;
@@ -151,7 +152,7 @@ namespace CryptoExchange.Net.Logging.Extensions
"[Sckt {SocketId}] discarding incomplete message of {NumBytes} bytes");
_receiveLoopStoppedWithException = LoggerMessage.Define<int>(
LogLevel.Warning,
LogLevel.Error,
new EventId(1024, "ReceiveLoopStoppedWithException"),
"[Sckt {SocketId}] receive loop stopped with exception");
@@ -3,7 +3,8 @@ using System;
namespace CryptoExchange.Net.Logging.Extensions
{
internal static class RateLimitGateLoggingExtensions
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static class RateLimitGateLoggingExtensions
{
private static readonly Action<ILogger, int, string, string, string, Exception?> _rateLimitRequestFailed;
private static readonly Action<ILogger, int, string, string, Exception?> _rateLimitConnectionFailed;
@@ -6,7 +6,8 @@ using System.Net.Http;
namespace CryptoExchange.Net.Logging.Extensions
{
internal static class RestApiClientLoggingExtensions
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static class RestApiClientLoggingExtensions
{
private static readonly Action<ILogger, int?, int?, long, string?, Exception?> _restApiErrorReceived;
private static readonly Action<ILogger, int?, int?, long, string?, Exception?> _restApiResponseReceived;
@@ -17,6 +18,9 @@ namespace CryptoExchange.Net.Logging.Extensions
private static readonly Action<ILogger, int, DateTime, Exception?> _restApiRateLimitRetry;
private static readonly Action<ILogger, int, DateTime, Exception?> _restApiRateLimitPauseUntil;
private static readonly Action<ILogger, int, RequestDefinition, string?, string, string, Exception?> _restApiSendRequest;
private static readonly Action<ILogger, string, Exception?> _restApiCheckingCache;
private static readonly Action<ILogger, string, Exception?> _restApiCacheHit;
private static readonly Action<ILogger, string, Exception?> _restApiCacheNotHit;
static RestApiClientLoggingExtensions()
@@ -65,6 +69,21 @@ namespace CryptoExchange.Net.Logging.Extensions
LogLevel.Debug,
new EventId(4008, "RestApiSendRequest"),
"[Req {RequestId}] Sending {Definition} request with body {Body}, query parameters {Query} and headers {Headers}");
_restApiCheckingCache = LoggerMessage.Define<string>(
LogLevel.Trace,
new EventId(4009, "RestApiCheckingCache"),
"Checking cache for key {Key}");
_restApiCacheHit = LoggerMessage.Define<string>(
LogLevel.Trace,
new EventId(4010, "RestApiCacheHit"),
"Cache hit for key {Key}");
_restApiCacheNotHit = LoggerMessage.Define<string>(
LogLevel.Trace,
new EventId(4011, "RestApiCacheNotHit"),
"Cache not hit for key {Key}");
}
public static void RestApiErrorReceived(this ILogger logger, int? requestId, HttpStatusCode? responseStatusCode, long responseTime, string? error)
@@ -111,5 +130,20 @@ namespace CryptoExchange.Net.Logging.Extensions
{
_restApiSendRequest(logger, requestId, definition, body, query, headers, null);
}
public static void CheckingCache(this ILogger logger, string key)
{
_restApiCheckingCache(logger, key, null);
}
public static void CacheHit(this ILogger logger, string key)
{
_restApiCacheHit(logger, key, null);
}
public static void CacheNotHit(this ILogger logger, string key)
{
_restApiCacheNotHit(logger, key, null);
}
}
}
@@ -3,7 +3,8 @@ using System;
namespace CryptoExchange.Net.Logging.Extensions
{
internal static class SocketApiClientLoggingExtension
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static class SocketApiClientLoggingExtension
{
private static readonly Action<ILogger, int, Exception?> _failedToAddSubscriptionRetryOnDifferentConnection;
private static readonly Action<ILogger, int, Exception?> _hasBeenPausedCantSubscribeAtThisMoment;
@@ -4,7 +4,8 @@ using Microsoft.Extensions.Logging;
namespace CryptoExchange.Net.Logging.Extensions
{
internal static class SocketConnectionLoggingExtension
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static class SocketConnectionLoggingExtension
{
private static readonly Action<ILogger, int, bool, Exception?> _activityPaused;
private static readonly Action<ILogger, int, Sockets.SocketConnection.SocketStatus, Sockets.SocketConnection.SocketStatus, Exception?> _socketStatusChanged;
@@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging;
namespace CryptoExchange.Net.Logging.Extensions
{
internal static class SymbolOrderBookLoggingExtensions
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static class SymbolOrderBookLoggingExtensions
{
private static readonly Action<ILogger, string, string, OrderBookStatus, OrderBookStatus, Exception?> _orderBookStatusChanged;
private static readonly Action<ILogger, string, string, Exception?> _orderBookStarting;
@@ -32,7 +32,7 @@ namespace CryptoExchange.Net.Objects
/// Wait for the AutoResetEvent to be set
/// </summary>
/// <returns></returns>
public Task<bool> WaitAsync(TimeSpan? timeout = null)
public Task<bool> WaitAsync(TimeSpan? timeout = null, CancellationToken ct = default)
{
lock (_waits)
{
@@ -44,22 +44,29 @@ namespace CryptoExchange.Net.Objects
}
else
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if(timeout != null)
{
var cancellationSource = new CancellationTokenSource(timeout.Value);
var registration = cancellationSource.Token.Register(() =>
{
lock (_waits)
{
tcs.TrySetResult(false);
if (ct.IsCancellationRequested)
return _completed;
// Not the cleanest but it works
_waits = new Queue<TaskCompletionSource<bool>>(_waits.Where(i => i != tcs));
}
}, useSynchronizationContext: false);
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (timeout.HasValue)
{
var timeoutSource = new CancellationTokenSource(timeout.Value);
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutSource.Token, ct);
ct = cancellationSource.Token;
}
var registration = ct.Register(() =>
{
lock (_waits)
{
tcs.TrySetResult(false);
// Not the cleanest but it works
_waits = new Queue<TaskCompletionSource<bool>>(_waits.Where(i => i != tcs));
}
}, useSynchronizationContext: false);
_waits.Enqueue(tcs);
return tcs.Task;
}
@@ -0,0 +1,10 @@
using CryptoExchange.Net.Interfaces;
using System;
namespace CryptoExchange.Net.Objects
{
internal class AuthTimeProvider : IAuthTimeProvider
{
public DateTime GetTime() => DateTime.UtcNow;
}
}
+42 -3
View File
@@ -273,6 +273,28 @@ namespace CryptoExchange.Net.Objects
return new WebCallResult(ResponseStatusCode, ResponseHeaders, ResponseTime, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, error);
}
/// <summary>
/// Copy the WebCallResult to a new data type
/// </summary>
/// <typeparam name="K">The new type</typeparam>
/// <param name="data">The data of the new type</param>
/// <returns></returns>
public WebCallResult<K> As<K>([AllowNull] K data)
{
return new WebCallResult<K>(ResponseStatusCode, ResponseHeaders, ResponseTime, 0, null, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, ResultDataSource.Server, data, Error);
}
/// <summary>
/// Copy the WebCallResult to a new data type
/// </summary>
/// <typeparam name="K">The new type</typeparam>
/// <param name="error">The error returned</param>
/// <returns></returns>
public WebCallResult<K> AsError<K>(Error error)
{
return new WebCallResult<K>(ResponseStatusCode, ResponseHeaders, ResponseTime, 0, null, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, ResultDataSource.Server, default, error);
}
/// <inheritdoc />
public override string ToString()
{
@@ -331,6 +353,11 @@ namespace CryptoExchange.Net.Objects
/// </summary>
public TimeSpan? ResponseTime { get; set; }
/// <summary>
/// The data source of this result
/// </summary>
public ResultDataSource DataSource { get; set; } = ResultDataSource.Server;
/// <summary>
/// Create a new result
/// </summary>
@@ -344,6 +371,7 @@ namespace CryptoExchange.Net.Objects
/// <param name="requestBody"></param>
/// <param name="requestMethod"></param>
/// <param name="requestHeaders"></param>
/// <param name="dataSource"></param>
/// <param name="data"></param>
/// <param name="error"></param>
public WebCallResult(
@@ -357,6 +385,7 @@ namespace CryptoExchange.Net.Objects
string? requestBody,
HttpMethod? requestMethod,
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? requestHeaders,
ResultDataSource dataSource,
[AllowNull] T data,
Error? error) : base(data, originalData, error)
{
@@ -370,6 +399,7 @@ namespace CryptoExchange.Net.Objects
RequestBody = requestBody;
RequestHeaders = requestHeaders;
RequestMethod = requestMethod;
DataSource = dataSource;
}
/// <summary>
@@ -393,7 +423,7 @@ namespace CryptoExchange.Net.Objects
/// Create a new error result
/// </summary>
/// <param name="error">The error</param>
public WebCallResult(Error? error) : this(null, null, null, null, null, null, null, null, null, null, default, error) { }
public WebCallResult(Error? error) : this(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, default, error) { }
/// <summary>
/// Copy the WebCallResult to a new data type
@@ -403,7 +433,7 @@ namespace CryptoExchange.Net.Objects
/// <returns></returns>
public new WebCallResult<K> As<K>([AllowNull] K data)
{
return new WebCallResult<K>(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, data, Error);
return new WebCallResult<K>(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, DataSource, data, Error);
}
/// <summary>
@@ -414,7 +444,16 @@ namespace CryptoExchange.Net.Objects
/// <returns></returns>
public new WebCallResult<K> AsError<K>(Error error)
{
return new WebCallResult<K>(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, default, error);
return new WebCallResult<K>(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, DataSource, default, error);
}
/// <summary>
/// Return a copy of this result with data source set to cache
/// </summary>
/// <returns></returns>
internal WebCallResult<T> Cached()
{
return new WebCallResult<T>(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, ResultDataSource.Cache, Data, Error);
}
/// <inheritdoc />
+4
View File
@@ -13,5 +13,9 @@
/// Form content type header
/// </summary>
public const string FormContentHeader = "application/x-www-form-urlencoded";
/// <summary>
/// Placeholder key for when request body should be set to the value of this KVP
/// </summary>
public const string BodyPlaceHolderKey = "_BODY_";
}
}
+34
View File
@@ -169,4 +169,38 @@
/// </summary>
Snapshot
}
/// <summary>
/// Reconnect policy
/// </summary>
public enum ReconnectPolicy
{
/// <summary>
/// Reconnect is disabled
/// </summary>
Disabled,
/// <summary>
/// Fixed delay of `ReconnectInterval` between retries
/// </summary>
FixedDelay,
/// <summary>
/// Backof policy of 2^`reconnectAttempt`, where `reconnectAttempt` has a max value of 5
/// </summary>
ExponentialBackoff
}
/// <summary>
/// The data source of the result
/// </summary>
public enum ResultDataSource
{
/// <summary>
/// From server
/// </summary>
Server,
/// <summary>
/// From cache
/// </summary>
Cache
}
}
@@ -3,7 +3,7 @@
/// <summary>
/// Base for order book options
/// </summary>
public class OrderBookOptions : ExchangeOptions
public class OrderBookOptions
{
/// <summary>
/// Whether or not checksum validation is enabled. Default is true, disabling will ignore checksum messages.
@@ -19,11 +19,7 @@
{
return new T
{
ApiCredentials = ApiCredentials?.Copy(),
OutputOriginalData = OutputOriginalData,
ChecksumValidationEnabled = ChecksumValidationEnabled,
Proxy = Proxy,
RequestTimeout = RequestTimeout
};
}
}
@@ -18,6 +18,16 @@ namespace CryptoExchange.Net.Objects.Options
/// </summary>
public TimeSpan TimestampRecalculationInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Whether caching is enabled. Caching will only be applied to GET http requests. The lifetime of cached results can be determined by the `CachingMaxAge` option
/// </summary>
public bool CachingEnabled { get; set; } = false;
/// <summary>
/// The max age of a cached entry, only used when the `CachingEnabled` options is set to true. When a cached entry is older than the max age it will be discarded and a new server request will be done
/// </summary>
public TimeSpan CachingMaxAge { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Create a copy of this options
/// </summary>
@@ -34,7 +44,9 @@ namespace CryptoExchange.Net.Objects.Options
Proxy = Proxy,
RequestTimeout = RequestTimeout,
RateLimiterEnabled = RateLimiterEnabled,
RateLimitingBehaviour = RateLimitingBehaviour
RateLimitingBehaviour = RateLimitingBehaviour,
CachingEnabled = CachingEnabled,
CachingMaxAge = CachingMaxAge,
};
}
}
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Objects.Sockets;
using System;
namespace CryptoExchange.Net.Objects.Options
@@ -9,15 +10,15 @@ namespace CryptoExchange.Net.Objects.Options
public class SocketExchangeOptions : ExchangeOptions
{
/// <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
/// The fixed time to wait between reconnect attempts, only used when `ReconnectPolicy` is set to `ReconnectPolicy.ExponentialBackoff`
/// </summary>
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Reconnect policy
/// </summary>
public ReconnectPolicy ReconnectPolicy { get; set; } = ReconnectPolicy.FixedDelay;
/// <summary>
/// Max number of concurrent resubscription tasks per socket after reconnecting a socket
/// </summary>
@@ -57,7 +58,7 @@ namespace CryptoExchange.Net.Objects.Options
{
ApiCredentials = ApiCredentials?.Copy(),
OutputOriginalData = OutputOriginalData,
AutoReconnect = AutoReconnect,
ReconnectPolicy = ReconnectPolicy,
DelayAfterConnect = DelayAfterConnect,
MaxConcurrentResubscriptionsPerSocket = MaxConcurrentResubscriptionsPerSocket,
ReconnectInterval = ReconnectInterval,
@@ -0,0 +1,29 @@
using System.Collections.Generic;
namespace CryptoExchange.Net.Objects
{
/// <summary>
/// Order string comparer, sorts by alphabetical order
/// </summary>
public class OrderedStringComparer : IComparer<string>
{
/// <summary>
/// Compare function
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public int Compare(string x, string y)
{
// Shortcuts: If both are null, they are the same.
if (x == null && y == null) return 0;
// If one is null and the other isn't, then the
// one that is null is "lesser".
if (x == null) return -1;
if (y == null) return 1;
return x.CompareTo(y);
}
}
}
@@ -3,6 +3,7 @@ using CryptoExchange.Net.Converters.SystemTextJson;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace CryptoExchange.Net.Objects
{
@@ -148,6 +149,27 @@ namespace CryptoExchange.Net.Objects
Add(key, DateTimeConverter.ConvertToSeconds(value));
}
/// <summary>
/// Add a datetime value as string seconds timestamp
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
public void AddSecondsString(string key, DateTime value)
{
Add(key, DateTimeConverter.ConvertToSeconds(value).ToString());
}
/// <summary>
/// Add a datetime value as string seconds timestamp. Not added if value is null
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
public void AddOptionalSecondsString(string key, DateTime? value)
{
if (value != null)
Add(key, DateTimeConverter.ConvertToSeconds(value).ToString());
}
/// <summary>
/// Add an enum value as the string value as mapped using the <see cref="MapAttribute" />
/// </summary>
@@ -166,7 +188,7 @@ namespace CryptoExchange.Net.Objects
public void AddEnumAsInt<T>(string key, T value)
{
var stringVal = EnumConverter.GetString(value);
Add(key, EnumConverter.GetString(int.Parse(stringVal))!);
Add(key, int.Parse(stringVal)!);
}
/// <summary>
@@ -193,5 +215,18 @@ namespace CryptoExchange.Net.Objects
Add(key, int.Parse(stringVal));
}
}
/// <summary>
/// Set the request body. Can be used to specify a simple value or array as the body instead of an object
/// </summary>
/// <param name="body">Body to set</param>
/// <exception cref="InvalidOperationException"></exception>
public void SetBody(object body)
{
if (this.Any())
throw new InvalidOperationException("Can't set body when other parameters already specified");
Add(Constants.BodyPlaceHolderKey, body);
}
}
}
@@ -48,18 +48,22 @@ namespace CryptoExchange.Net.Objects
/// Request weight
/// </summary>
public int Weight { get; set; } = 1;
/// <summary>
/// Rate limit gate to use
/// </summary>
public IRateLimitGate? RateLimitGate { get; set; }
/// <summary>
/// Rate limit for this specific endpoint
/// Individual endpoint rate limit guard to use
/// </summary>
public int? EndpointLimitCount { get; set; }
public IRateLimitGuard? LimitGuard { get; set; }
/// <summary>
/// Rate limit period for this specific endpoint
/// Whether this request should never be cached
/// </summary>
public TimeSpan? EndpointLimitPeriod { get; set; }
public bool PreventCaching { get; set; }
/// <summary>
/// ctor
@@ -41,13 +41,13 @@ namespace CryptoExchange.Net.Objects
/// <param name="method">The HttpMethod</param>
/// <param name="path">Endpoint path</param>
/// <param name="rateLimitGate">The rate limit gate</param>
/// <param name="endpointLimitCount">The limit count for this specific endpoint</param>
/// <param name="endpointLimitPeriod">The period for the limit for this specific endpoint</param>
/// <param name="limitGuard">The rate limit guard for this specific endpoint</param>
/// <param name="weight">Request weight</param>
/// <param name="authenticated">Endpoint is authenticated</param>
/// <param name="requestBodyFormat">Request body format</param>
/// <param name="parameterPosition">Parameter position</param>
/// <param name="arraySerialization">Array serialization type</param>
/// <param name="preventCaching">Prevent request caching</param>
/// <returns></returns>
public RequestDefinition GetOrCreate(
HttpMethod method,
@@ -55,11 +55,11 @@ namespace CryptoExchange.Net.Objects
IRateLimitGate? rateLimitGate,
int weight,
bool authenticated,
int? endpointLimitCount = null,
TimeSpan? endpointLimitPeriod = null,
IRateLimitGuard? limitGuard = null,
RequestBodyFormat? requestBodyFormat = null,
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null)
ArrayParametersSerialization? arraySerialization = null,
bool? preventCaching = null)
{
if (!_definitions.TryGetValue(method + path, out var def))
@@ -67,13 +67,13 @@ namespace CryptoExchange.Net.Objects
def = new RequestDefinition(path, method)
{
Authenticated = authenticated,
EndpointLimitCount = endpointLimitCount,
EndpointLimitPeriod = endpointLimitPeriod,
LimitGuard = limitGuard,
RateLimitGate = rateLimitGate,
Weight = weight,
ArraySerialization = arraySerialization,
RequestBodyFormat = requestBodyFormat,
ParameterPosition = parameterPosition,
PreventCaching = preventCaching ?? false
};
_definitions.TryAdd(method + path, def);
}
+84 -11
View File
@@ -14,9 +14,14 @@ namespace CryptoExchange.Net.Objects.Sockets
public DateTime Timestamp { get; set; }
/// <summary>
/// The topic of the update, what symbol/asset etc..
/// The stream producing the update
/// </summary>
public string? Topic { get; set; }
public string? StreamId { get; set; }
/// <summary>
/// The symbol the update is for
/// </summary>
public string? Symbol { get; set; }
/// <summary>
/// The original data that was received, only available when OutputOriginalData is set to true in the client options
@@ -33,10 +38,11 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary>
public T Data { get; set; }
internal DataEvent(T data, string? topic, string? originalData, DateTime timestamp, SocketUpdateType? updateType)
internal DataEvent(T data, string? streamId, string? symbol, string? originalData, DateTime timestamp, SocketUpdateType? updateType)
{
Data = data;
Topic = topic;
StreamId = streamId;
Symbol = symbol;
OriginalData = originalData;
Timestamp = timestamp;
UpdateType = updateType;
@@ -50,7 +56,7 @@ namespace CryptoExchange.Net.Objects.Sockets
/// <returns></returns>
public DataEvent<K> As<K>(K data)
{
return new DataEvent<K>(data, Topic, OriginalData, Timestamp, UpdateType);
return new DataEvent<K>(data, StreamId, Symbol, OriginalData, Timestamp, UpdateType);
}
/// <summary>
@@ -58,11 +64,11 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary>
/// <typeparam name="K">The type of the new data</typeparam>
/// <param name="data">The new data</param>
/// <param name="topic">The new topic</param>
/// <param name="symbol">The new symbol</param>
/// <returns></returns>
public DataEvent<K> As<K>(K data, string? topic)
public DataEvent<K> As<K>(K data, string? symbol)
{
return new DataEvent<K>(data, topic, OriginalData, Timestamp, UpdateType);
return new DataEvent<K>(data, StreamId, symbol, OriginalData, Timestamp, UpdateType);
}
/// <summary>
@@ -70,12 +76,79 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary>
/// <typeparam name="K">The type of the new data</typeparam>
/// <param name="data">The new data</param>
/// <param name="topic">The new topic</param>
/// <param name="streamId">The new stream id</param>
/// <param name="symbol">The new symbol</param>
/// <param name="updateType">The type of update</param>
/// <returns></returns>
public DataEvent<K> As<K>(K data, string? topic, SocketUpdateType updateType)
public DataEvent<K> As<K>(K data, string streamId, string? symbol, SocketUpdateType updateType)
{
return new DataEvent<K>(data, topic, OriginalData, Timestamp, updateType);
return new DataEvent<K>(data, streamId, symbol, OriginalData, Timestamp, updateType);
}
/// <summary>
/// Specify the symbol
/// </summary>
/// <param name="symbol"></param>
/// <returns></returns>
public DataEvent<T> WithSymbol(string symbol)
{
Symbol = symbol;
return this;
}
/// <summary>
/// Specify the update type
/// </summary>
/// <param name="type"></param>
/// <returns></returns>
public DataEvent<T> WithUpdateType(SocketUpdateType type)
{
UpdateType = type;
return this;
}
/// <summary>
/// Specify the stream id
/// </summary>
/// <param name="streamId"></param>
/// <returns></returns>
public DataEvent<T> WithStreamId(string streamId)
{
StreamId = streamId;
return this;
}
/// <summary>
/// Create a CallResult from this DataEvent
/// </summary>
/// <returns></returns>
public CallResult<T> ToCallResult()
{
return new CallResult<T>(Data, OriginalData, null);
}
/// <summary>
/// Create a CallResult from this DataEvent
/// </summary>
/// <returns></returns>
public CallResult<K> ToCallResult<K>(K data)
{
return new CallResult<K>(data, OriginalData, null);
}
/// <summary>
/// Create a CallResult from this DataEvent
/// </summary>
/// <returns></returns>
public CallResult<K> ToCallResult<K>(Error error)
{
return new CallResult<K>(default, OriginalData, error);
}
/// <inheritdoc />
public override string ToString()
{
return $"{StreamId} - {(Symbol == null ? "" : (Symbol + " - "))}{(UpdateType == null ? "" : (UpdateType + " - "))}{Data}";
}
}
}
@@ -26,20 +26,20 @@ namespace CryptoExchange.Net.Objects.Sockets
public IDictionary<string, string> Cookies { get; set; } = new Dictionary<string, string>();
/// <summary>
/// The time to wait between reconnect attempts
/// The fixed time to wait between reconnect attempts, only used when `ReconnectPolicy` is set to `ReconnectPolicy.ExponentialBackoff`
/// </summary>
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Reconnect policy
/// </summary>
public ReconnectPolicy ReconnectPolicy { get; set; } = ReconnectPolicy.FixedDelay;
/// <summary>
/// Proxy for the connection
/// </summary>
public ApiProxy? Proxy { get; set; }
/// <summary>
/// Whether the socket should automatically reconnect when connection is lost
/// </summary>
public bool AutoReconnect { get; set; }
/// <summary>
/// The maximum time of no data received before considering the connection lost and closting/reconnecting the socket
/// </summary>
@@ -68,11 +68,11 @@ namespace CryptoExchange.Net.Objects.Sockets
/// ctor
/// </summary>
/// <param name="uri">Uri</param>
/// <param name="autoReconnect">Auto reconnect</param>
public WebSocketParameters(Uri uri, bool autoReconnect)
/// <param name="policy">Reconnect policy</param>
public WebSocketParameters(Uri uri, ReconnectPolicy policy)
{
Uri = uri;
AutoReconnect = autoReconnect;
ReconnectPolicy = policy;
}
}
}
@@ -0,0 +1,30 @@
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects.Options;
using System;
namespace CryptoExchange.Net.OrderBook
{
/// <inheritdoc />
public class OrderBookFactory<TOptions> : IOrderBookFactory<TOptions> where TOptions: OrderBookOptions
{
private readonly Func<string, Action<TOptions>?, ISymbolOrderBook> _symbolCtor;
private readonly Func<string, string, Action<TOptions>?, ISymbolOrderBook> _assetsCtor;
/// <summary>
/// ctor
/// </summary>
/// <param name="symbolCtor"></param>
/// <param name="assetsCtor"></param>
public OrderBookFactory(Func<string, Action<TOptions>?, ISymbolOrderBook> symbolCtor, Func<string, string, Action<TOptions>?, ISymbolOrderBook> assetsCtor)
{
_symbolCtor = symbolCtor;
_assetsCtor = assetsCtor;
}
/// <inheritdoc />
public ISymbolOrderBook Create(string symbol, Action<TOptions>? options = null) => _symbolCtor(symbol, options);
/// <inheritdoc />
public ISymbolOrderBook Create(string baseAsset, string quoteAsset, Action<TOptions>? options = null) => _assetsCtor(baseAsset, quoteAsset, options);
}
}
@@ -810,7 +810,7 @@ namespace CryptoExchange.Net.OrderBook
{
if (lastUpdateId <= LastSequenceNumber)
{
_logger.OrderBookUpdateSkipped(Api, Symbol, firstUpdateId, lastUpdateId);
_logger.OrderBookUpdateSkipped(Api, Symbol, lastUpdateId, LastSequenceNumber);
return;
}
@@ -12,9 +12,22 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// </summary>
public class SingleLimitGuard : IRateLimitGuard
{
/// <summary>
/// Default endpoint limit
/// </summary>
public static Func<RequestDefinition, string, SecureString?, string> Default { get; } = new Func<RequestDefinition, string, SecureString?, string>((def, host, key) => def.Path + def.Method);
/// <summary>
/// Endpoint limit per API key
/// </summary>
public static Func<RequestDefinition, string, SecureString?, string> PerApiKey { get; } = new Func<RequestDefinition, string, SecureString?, string>((def, host, key) => def.Path + def.Method);
private readonly Dictionary<string, IWindowTracker> _trackers;
private readonly RateLimitWindowType _windowType;
private readonly double? _decayRate;
private readonly int _limit;
private readonly TimeSpan _period;
private readonly Func<RequestDefinition, string, SecureString?, string> _keySelector;
/// <inheritdoc />
public string Name => "EndpointLimitGuard";
@@ -25,20 +38,28 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// <summary>
/// ctor
/// </summary>
public SingleLimitGuard(RateLimitWindowType windowType, double? decayRate = null)
public SingleLimitGuard(
int limit,
TimeSpan period,
RateLimitWindowType windowType,
double? decayRate = null,
Func<RequestDefinition, string, SecureString?, string>? keySelector = null)
{
_limit = limit;
_period = period;
_windowType = windowType;
_decayRate = decayRate;
_keySelector = keySelector ?? Default;
_trackers = new Dictionary<string, IWindowTracker>();
}
/// <inheritdoc />
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
var key = definition.Path + definition.Method;
var key = _keySelector(definition, host, apiKey);
if (!_trackers.TryGetValue(key, out var tracker))
{
tracker = CreateTracker(definition.EndpointLimitCount!.Value, definition.EndpointLimitPeriod!.Value);
tracker = CreateTracker();
_trackers.Add(key, tracker);
}
@@ -46,27 +67,27 @@ namespace CryptoExchange.Net.RateLimiting.Guards
if (delay == default)
return LimitCheck.NotNeeded;
return LimitCheck.Needed(delay, definition.EndpointLimitCount!.Value, definition.EndpointLimitPeriod!.Value, tracker.Current);
return LimitCheck.Needed(delay, _limit, _period, tracker.Current);
}
/// <inheritdoc />
public RateLimitState ApplyWeight(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
var key = definition.Path + definition.Method;
var key = _keySelector(definition, host, apiKey);
var tracker = _trackers[key];
tracker.ApplyWeight(requestWeight);
return RateLimitState.Applied(definition.EndpointLimitCount!.Value, definition.EndpointLimitPeriod!.Value, tracker.Current);
return RateLimitState.Applied(_limit, _period, tracker.Current);
}
/// <summary>
/// Create a new WindowTracker
/// </summary>
/// <returns></returns>
protected IWindowTracker CreateTracker(int limit, TimeSpan timeSpan)
protected IWindowTracker CreateTracker()
{
return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(limit, timeSpan)
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(limit, timeSpan) :
new DecayWindowTracker(limit, timeSpan, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(_limit, _period)
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(_limit, _period) :
new DecayWindowTracker(_limit, _period, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
}
}
}
@@ -32,13 +32,6 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
/// <returns></returns>
Task SetRetryAfterGuardAsync(DateTime retryAfter);
/// <summary>
/// Set the SingleLimitGuard for handling individual endpoint rate limits
/// </summary>
/// <param name="guard"></param>
/// <returns></returns>
IRateLimitGate SetSingleLimitGuard(SingleLimitGuard guard);
/// <summary>
/// Returns the 'retry after' timestamp if set
/// </summary>
@@ -65,14 +58,14 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="itemId">Id of the item to check</param>
/// <param name="guard">The guard</param>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="baseAddress">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="requestWeight">Request weight</param>
/// <param name="behaviour">Behaviour when rate limit is hit</param>
/// <param name="ct">Cancelation token</param>
/// <returns>Error if RateLimitingBehaviour is Fail and rate limit is hit</returns>
Task<CallResult> ProcessSingleAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string baseAddress, SecureString? apiKey, int requestWeight, RateLimitingBehaviour behaviour, CancellationToken ct);
Task<CallResult> ProcessSingleAsync(ILogger logger, int itemId, IRateLimitGuard guard, RateLimitItemType type, RequestDefinition definition, string baseAddress, SecureString? apiKey, RateLimitingBehaviour behaviour, CancellationToken ct);
}
}
@@ -16,7 +16,6 @@ namespace CryptoExchange.Net.RateLimiting
/// <inheritdoc />
public class RateLimitGate : IRateLimitGate
{
private IRateLimitGuard _singleLimitGuard = new SingleLimitGuard(RateLimitWindowType.Sliding);
private readonly ConcurrentBag<IRateLimitGuard> _guards;
private readonly SemaphoreSlim _semaphore;
private readonly string _name;
@@ -53,16 +52,23 @@ namespace CryptoExchange.Net.RateLimiting
}
/// <inheritdoc />
public async Task<CallResult> ProcessSingleAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, CancellationToken ct)
public async Task<CallResult> ProcessSingleAsync(
ILogger logger,
int itemId,
IRateLimitGuard guard,
RateLimitItemType type,
RequestDefinition definition,
string host,
SecureString? apiKey,
RateLimitingBehaviour rateLimitingBehaviour,
CancellationToken ct)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
if (requestWeight == 0)
requestWeight = 1;
_waitingCount++;
try
{
return await CheckGuardsAsync(new IRateLimitGuard[] { _singleLimitGuard }, logger, itemId, type, definition, host, apiKey, requestWeight, rateLimitingBehaviour, ct).ConfigureAwait(false);
return await CheckGuardsAsync(new IRateLimitGuard[] { guard }, logger, itemId, type, definition, host, apiKey, 1, rateLimitingBehaviour, ct).ConfigureAwait(false);
}
finally
{
@@ -101,7 +107,7 @@ namespace CryptoExchange.Net.RateLimiting
logger.RateLimitDelayingRequest(itemId, definition.Path, result.Delay, guard.Name, description);
RateLimitTriggered?.Invoke(new RateLimitEvent(_name, guard.Description, definition, host, result.Current, requestWeight, result.Limit, result.Period, result.Delay, rateLimitingBehaviour));
await Task.Delay(result.Delay, ct).ConfigureAwait(false);
await Task.Delay((int)result.Delay.TotalMilliseconds + 1, ct).ConfigureAwait(false);
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
return await CheckGuardsAsync(guards, logger, itemId, type, definition, host, apiKey, requestWeight, rateLimitingBehaviour, ct).ConfigureAwait(false);
}
@@ -130,13 +136,6 @@ namespace CryptoExchange.Net.RateLimiting
return this;
}
/// <inheritdoc />
public IRateLimitGate SetSingleLimitGuard(SingleLimitGuard guard)
{
_singleLimitGuard = guard;
return this;
}
/// <inheritdoc />
public async Task SetRetryAfterGuardAsync(DateTime retryAfter)
{
@@ -80,7 +80,10 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private TimeSpan DetermineWaitTime(int requestWeight)
{
var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0);
return TimeSpan.FromMilliseconds(Math.Ceiling(weightToRemove / DecreaseRate) * TimePeriod.TotalMilliseconds);
var result = TimeSpan.FromMilliseconds(Math.Ceiling(weightToRemove / DecreaseRate) * TimePeriod.TotalMilliseconds);
if (result < TimeSpan.Zero)
return TimeSpan.Zero;
return result;
}
}
}
@@ -97,7 +97,10 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private TimeSpan DetermineWaitTime()
{
var checkTime = DateTime.UtcNow;
return (_nextReset!.Value - checkTime) + _fixedWindowBuffer;
var result = (_nextReset!.Value - checkTime) + _fixedWindowBuffer;
if (result < TimeSpan.Zero)
return TimeSpan.Zero;
return result;
}
}
}
@@ -93,7 +93,10 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
var checkTime = DateTime.UtcNow;
var startCurrentWindow = checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks));
var wait = startCurrentWindow.Add(TimePeriod) - checkTime;
return wait.Add(_fixedWindowBuffer);
var result = wait.Add(_fixedWindowBuffer);
if (result < TimeSpan.Zero)
return TimeSpan.Zero;
return result;
}
}
}
@@ -16,6 +16,11 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private readonly List<LimitEntry> _entries;
private int _currentWeight = 0;
/// <summary>
/// Additional wait time to apply to account for fluctuating request times
/// </summary>
private static readonly TimeSpan _slidingWindowBuffer = TimeSpan.FromMilliseconds(1000);
public SlidingWindowTracker(int limit, TimeSpan period)
{
Limit = limit;
@@ -89,7 +94,10 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
removedWeight += entry.Weight;
if (removedWeight >= weightToRemove)
{
return entry.Timestamp + TimePeriod - DateTime.UtcNow;
var result = entry.Timestamp + TimePeriod + _slidingWindowBuffer - DateTime.UtcNow;
if (result < TimeSpan.Zero)
return TimeSpan.Zero;
return result;
}
}
@@ -19,6 +19,7 @@ namespace CryptoExchange.Net.Requests
if (client == null)
{
var handler = new HttpClientHandler();
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
if (proxy != null)
{
handler.Proxy = new WebProxy
@@ -47,6 +47,7 @@ namespace CryptoExchange.Net.Sockets
private ProcessState _processState;
private DateTime _lastReconnectTime;
private string _baseAddress;
private int _reconnectAttempt;
private const int _receiveBufferSize = 1048576;
private const int _sendBufferSize = 4096;
@@ -107,7 +108,7 @@ namespace CryptoExchange.Net.Sockets
public event Func<Task>? OnClose;
/// <inheritdoc />
public event Action<WebSocketMessageType, ReadOnlyMemory<byte>>? OnStreamMessage;
public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task>? OnStreamMessage;
/// <inheritdoc />
public event Func<int, Task>? OnRequestSent;
@@ -244,14 +245,15 @@ namespace CryptoExchange.Net.Sockets
await Task.Delay(50).ConfigureAwait(false);
await _closeTask.ConfigureAwait(false);
_closeTask = null;
if (!_stopRequested)
_closeTask = null;
if (!Parameters.AutoReconnect)
if (Parameters.ReconnectPolicy == ReconnectPolicy.Disabled)
{
_processState = ProcessState.Idle;
await (OnClose?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false);
return;
}
}
if (!_stopRequested)
{
@@ -259,9 +261,9 @@ namespace CryptoExchange.Net.Sockets
await (OnReconnecting?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false);
}
var sinceLastReconnect = DateTime.UtcNow - _lastReconnectTime;
if (sinceLastReconnect < Parameters.ReconnectInterval)
await Task.Delay(Parameters.ReconnectInterval - sinceLastReconnect).ConfigureAwait(false);
// Delay here to prevent very repid looping when a connection to the server is accepted and immediately disconnected
var initialDelay = GetReconnectDelay();
await Task.Delay(initialDelay).ConfigureAwait(false);
while (!_stopRequested)
{
@@ -282,13 +284,17 @@ namespace CryptoExchange.Net.Sockets
_ctsSource = new CancellationTokenSource();
while (_sendBuffer.TryDequeue(out _)) { } // Clear send buffer
_reconnectAttempt++;
var connected = await ConnectInternalAsync().ConfigureAwait(false);
if (!connected)
{
await Task.Delay(Parameters.ReconnectInterval).ConfigureAwait(false);
// Delay between reconnect attempts
var delay = GetReconnectDelay();
await Task.Delay(delay).ConfigureAwait(false);
continue;
}
_reconnectAttempt = 0;
_lastReconnectTime = DateTime.UtcNow;
await (OnReconnected?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false);
break;
@@ -298,16 +304,35 @@ namespace CryptoExchange.Net.Sockets
_processState = ProcessState.Idle;
}
/// <inheritdoc />
public virtual void Send(int id, string data, int weight)
private TimeSpan GetReconnectDelay()
{
if (_ctsSource.IsCancellationRequested)
return;
if (_reconnectAttempt == 0)
{
// Means this is directly after disconnecting. Only delay if the last reconnect time is very recent
var sinceLastReconnect = DateTime.UtcNow - _lastReconnectTime;
if (sinceLastReconnect < TimeSpan.FromSeconds(5))
return TimeSpan.FromSeconds(5) - sinceLastReconnect;
return TimeSpan.FromMilliseconds(1);
}
var delay = Parameters.ReconnectPolicy == ReconnectPolicy.FixedDelay ? Parameters.ReconnectInterval : TimeSpan.FromSeconds(Math.Pow(2, Math.Min(5, _reconnectAttempt)));
if (delay > TimeSpan.Zero)
return delay;
return TimeSpan.FromMilliseconds(1);
}
/// <inheritdoc />
public virtual bool Send(int id, string data, int weight)
{
if (_ctsSource.IsCancellationRequested || _processState != ProcessState.Processing)
return false;
var bytes = Parameters.Encoding.GetBytes(data);
_logger.SocketAddingBytesToSendBuffer(Id, id, bytes);
_sendBuffer.Enqueue(new SendItem { Id = id, Weight = weight, Bytes = bytes });
_sendEvent.Set();
return true;
}
/// <inheritdoc />
@@ -366,9 +391,7 @@ namespace CryptoExchange.Net.Sockets
if (_disposed)
return;
//_closeState = CloseState.Closing;
_ctsSource.Cancel();
_sendEvent.Set();
if (_socket.State == WebSocketState.Open)
{
@@ -413,6 +436,7 @@ namespace CryptoExchange.Net.Sockets
_disposed = true;
_socket.Dispose();
_ctsSource?.Dispose();
_sendEvent.Dispose();
_logger.SocketDisposed(Id);
}
@@ -427,10 +451,15 @@ namespace CryptoExchange.Net.Sockets
{
while (true)
{
if (_ctsSource.IsCancellationRequested)
try
{
if (!_sendBuffer.Any())
await _sendEvent.WaitAsync(ct: _ctsSource.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
await _sendEvent.WaitAsync().ConfigureAwait(false);
}
if (_ctsSource.IsCancellationRequested)
break;
@@ -439,11 +468,19 @@ namespace CryptoExchange.Net.Sockets
{
if (Parameters.RateLimiter != null)
{
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, data.Id, RateLimitItemType.Request, requestDefinition, _baseAddress, null, data.Weight, Parameters.RateLimitingBehaviour, _ctsSource.Token).ConfigureAwait(false);
if (!limitResult)
try
{
await (OnRequestRateLimited?.Invoke(data.Id) ?? Task.CompletedTask).ConfigureAwait(false);
continue;
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, data.Id, RateLimitItemType.Request, requestDefinition, _baseAddress, null, data.Weight, Parameters.RateLimitingBehaviour, _ctsSource.Token).ConfigureAwait(false);
if (!limitResult)
{
await (OnRequestRateLimited?.Invoke(data.Id) ?? Task.CompletedTask).ConfigureAwait(false);
continue;
}
}
catch (OperationCanceledException)
{
// canceled
break;
}
}
@@ -476,7 +513,8 @@ namespace CryptoExchange.Net.Sockets
// Make sure we at least let the owner know there was an error
_logger.SocketSendLoopStoppedWithException(Id, e.Message, e);
await (OnError?.Invoke(e) ?? Task.CompletedTask).ConfigureAwait(false);
throw;
if (_closeTask?.IsCompleted != false)
_closeTask = CloseInternalAsync();
}
finally
{
@@ -551,7 +589,7 @@ namespace CryptoExchange.Net.Sockets
{
// Received a complete message and it's not multi part
_logger.SocketReceivedSingleMessage(Id, receiveResult.Count);
ProcessData(receiveResult.MessageType, new ReadOnlyMemory<byte>(buffer.Array, buffer.Offset, receiveResult.Count));
await ProcessData(receiveResult.MessageType, new ReadOnlyMemory<byte>(buffer.Array, buffer.Offset, receiveResult.Count)).ConfigureAwait(false);
}
else
{
@@ -586,7 +624,7 @@ namespace CryptoExchange.Net.Sockets
{
_logger.SocketReassembledMessage(Id, multipartStream!.Length);
// Get the underlying buffer of the memorystream holding the written data and delimit it (GetBuffer return the full array, not only the written part)
ProcessData(receiveResult.MessageType, new ReadOnlyMemory<byte>(multipartStream.GetBuffer(), 0, (int)multipartStream.Length));
await ProcessData(receiveResult.MessageType, new ReadOnlyMemory<byte>(multipartStream.GetBuffer(), 0, (int)multipartStream.Length)).ConfigureAwait(false);
}
else
{
@@ -602,7 +640,8 @@ namespace CryptoExchange.Net.Sockets
// Make sure we at least let the owner know there was an error
_logger.SocketReceiveLoopStoppedWithException(Id, e);
await (OnError?.Invoke(e) ?? Task.CompletedTask).ConfigureAwait(false);
throw;
if (_closeTask?.IsCompleted != false)
_closeTask = CloseInternalAsync();
}
finally
{
@@ -616,10 +655,10 @@ namespace CryptoExchange.Net.Sockets
/// <param name="type"></param>
/// <param name="data"></param>
/// <returns></returns>
protected void ProcessData(WebSocketMessageType type, ReadOnlyMemory<byte> data)
protected async Task ProcessData(WebSocketMessageType type, ReadOnlyMemory<byte> data)
{
LastActionTime = DateTime.UtcNow;
OnStreamMessage?.Invoke(type, data);
await (OnStreamMessage?.Invoke(type, data) ?? Task.CompletedTask).ConfigureAwait(false);
}
/// <summary>
@@ -660,7 +699,6 @@ namespace CryptoExchange.Net.Sockets
// any exception here will stop the timeout checking, but do so silently unless the socket get's stopped.
// Make sure we at least let the owner know there was an error
await (OnError?.Invoke(e) ?? Task.CompletedTask).ConfigureAwait(false);
throw;
}
}
@@ -685,10 +723,14 @@ namespace CryptoExchange.Net.Sockets
var checkTime = DateTime.UtcNow;
if (checkTime - _lastReceivedMessagesUpdate > TimeSpan.FromSeconds(1))
{
foreach (var msg in _receivedMessages.ToList()) // To list here because we're removing from the list
for (var i = 0; i < _receivedMessages.Count; i++)
{
var msg = _receivedMessages[i];
if (checkTime - msg.Timestamp > TimeSpan.FromSeconds(3))
{
_receivedMessages.Remove(msg);
i--;
}
}
_lastReceivedMessagesUpdate = checkTime;
@@ -729,7 +771,7 @@ namespace CryptoExchange.Net.Sockets
public int Id { get; set; }
/// <summary>
/// The request id
/// The request weight
/// </summary>
public int Weight { get; set; }
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace CryptoExchange.Net.Sockets
{
/// <summary>
/// Dedicated connection configuration
/// </summary>
public class DedicatedConnectionConfig
{
/// <summary>
/// Socket address
/// </summary>
public string SocketAddress { get; set; } = string.Empty;
/// <summary>
/// authenticated
/// </summary>
public bool Authenticated { get; set; }
}
}
+69 -22
View File
@@ -1,6 +1,7 @@
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.Requests;
using System;
using System.Collections.Generic;
using System.Threading;
@@ -18,16 +19,22 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public int Id { get; } = ExchangeHelpers.NextId();
/// <summary>
/// Can handle data
/// </summary>
public bool CanHandleData => true;
/// <summary>
/// Has this query been completed
/// </summary>
public bool Completed { get; set; }
/// <summary>
/// The number of required responses. Can be more than 1 when for example subscribing multiple symbols streams in a single request,
/// and each symbol receives it's own confirmation response
/// </summary>
public int RequiredResponses { get; set; } = 1;
/// <summary>
/// The current number of responses received on this query
/// </summary>
public int CurrentResponses { get; set; }
/// <summary>
/// Timestamp of when the request was send
/// </summary>
@@ -46,7 +53,7 @@ namespace CryptoExchange.Net.Sockets
/// <summary>
/// Wait event for the calling message processing thread
/// </summary>
public ManualResetEvent? ContinueAwaiter { get; set; }
public AsyncResetEvent? ContinueAwaiter { get; set; }
/// <summary>
/// Strings to match this query to a received message
@@ -112,11 +119,12 @@ namespace CryptoExchange.Net.Sockets
}
/// <summary>
/// Wait untill timeout or the request is competed
/// Wait until timeout or the request is completed
/// </summary>
/// <param name="timeout"></param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public async Task WaitAsync(TimeSpan timeout) => await _event.WaitAsync(timeout).ConfigureAwait(false);
public async Task WaitAsync(TimeSpan timeout, CancellationToken ct) => await _event.WaitAsync(timeout, ct).ConfigureAwait(false);
/// <inheritdoc />
public virtual CallResult<object> Deserialize(IMessageAccessor message, Type type) => message.Deserialize(type);
@@ -138,23 +146,24 @@ namespace CryptoExchange.Net.Sockets
/// <param name="message"></param>
/// <param name="connection"></param>
/// <returns></returns>
public abstract CallResult Handle(SocketConnection connection, DataEvent<object> message);
public abstract Task<CallResult> Handle(SocketConnection connection, DataEvent<object> message);
}
/// <summary>
/// Query
/// </summary>
/// <typeparam name="TResponse">Response object type</typeparam>
public abstract class Query<TResponse> : Query
/// <typeparam name="TServerResponse">The type returned from the server</typeparam>
/// <typeparam name="THandlerResponse">The type to be returned to the caller</typeparam>
public abstract class Query<TServerResponse, THandlerResponse> : Query
{
/// <inheritdoc />
public override Type? GetMessageType(IMessageAccessor message) => typeof(TResponse);
public override Type? GetMessageType(IMessageAccessor message) => typeof(TServerResponse);
/// <summary>
/// The typed call result
/// </summary>
public CallResult<TResponse>? TypedResult => (CallResult<TResponse>?)Result;
public CallResult<THandlerResponse>? TypedResult => (CallResult<THandlerResponse>?)Result;
/// <summary>
/// ctor
@@ -167,13 +176,26 @@ namespace CryptoExchange.Net.Sockets
}
/// <inheritdoc />
public override CallResult Handle(SocketConnection connection, DataEvent<object> message)
public override async Task<CallResult> Handle(SocketConnection connection, DataEvent<object> message)
{
Completed = true;
Response = message.Data;
Result = HandleMessage(connection, message.As((TResponse)message.Data));
_event.Set();
ContinueAwaiter?.WaitOne();
CurrentResponses++;
if (CurrentResponses == RequiredResponses)
{
Completed = true;
Response = message.Data;
}
if (Result?.Success != false)
// If an error result is already set don't override that
Result = HandleMessage(connection, message.As((TServerResponse)message.Data));
if (CurrentResponses == RequiredResponses)
{
_event.Set();
if (ContinueAwaiter != null)
await ContinueAwaiter.WaitAsync().ConfigureAwait(false);
}
return Result;
}
@@ -183,7 +205,7 @@ namespace CryptoExchange.Net.Sockets
/// <param name="connection"></param>
/// <param name="message"></param>
/// <returns></returns>
public virtual CallResult<TResponse> HandleMessage(SocketConnection connection, DataEvent<TResponse> message) => new CallResult<TResponse>(message.Data, message.OriginalData, null);
public abstract CallResult<THandlerResponse> HandleMessage(SocketConnection connection, DataEvent<TServerResponse> message);
/// <inheritdoc />
public override void Timeout()
@@ -192,7 +214,7 @@ namespace CryptoExchange.Net.Sockets
return;
Completed = true;
Result = new CallResult<TResponse>(new CancellationRequestedError(null, "Query timeout", null));
Result = new CallResult<THandlerResponse>(new CancellationRequestedError(null, "Query timeout", null));
ContinueAwaiter?.Set();
_event.Set();
}
@@ -200,10 +222,35 @@ namespace CryptoExchange.Net.Sockets
/// <inheritdoc />
public override void Fail(Error error)
{
Result = new CallResult<TResponse>(error);
Result = new CallResult<THandlerResponse>(error);
Completed = true;
ContinueAwaiter?.Set();
_event.Set();
}
}
/// <summary>
/// Query
/// </summary>
/// <typeparam name="TResponse">Response object type</typeparam>
public abstract class Query<TResponse> : Query<TResponse, TResponse>
{
/// <summary>
/// ctor
/// </summary>
/// <param name="request"></param>
/// <param name="authenticated"></param>
/// <param name="weight"></param>
protected Query(object request, bool authenticated, int weight = 1) : base(request, authenticated, weight)
{
}
/// <summary>
/// Handle the query response
/// </summary>
/// <param name="connection"></param>
/// <param name="message"></param>
/// <returns></returns>
public override CallResult<TResponse> HandleMessage(SocketConnection connection, DataEvent<TResponse> message) => message.ToCallResult();
}
}
+80 -50
View File
@@ -175,6 +175,11 @@ namespace CryptoExchange.Net.Sockets
}
}
/// <summary>
/// Whether this connection should be kept alive even when there is no subscription
/// </summary>
public bool DedicatedRequestConnection { get; internal set; }
private bool _pausedActivity;
private readonly object _listenersLock;
private readonly List<IMessageProcessor> _listeners;
@@ -408,14 +413,14 @@ namespace CryptoExchange.Net.Sockets
/// <param name="data"></param>
/// <param name="type"></param>
/// <returns></returns>
protected virtual void HandleStreamMessage(WebSocketMessageType type, ReadOnlyMemory<byte> data)
protected virtual async Task HandleStreamMessage(WebSocketMessageType type, ReadOnlyMemory<byte> data)
{
var sw = Stopwatch.StartNew();
var receiveTime = DateTime.UtcNow;
string? originalData = null;
// 1. Decrypt/Preprocess if necessary
data = ApiClient.PreprocessStreamMessage(type, data);
data = ApiClient.PreprocessStreamMessage(this, type, data);
// 2. Read data into accessor
_accessor.Read(data);
@@ -443,7 +448,7 @@ namespace CryptoExchange.Net.Sockets
// 4. Get the listeners interested in this message
List<IMessageProcessor> processors;
lock (_listenersLock)
processors = _listeners.Where(s => s.ListenerIdentifiers.Contains(listenId) && s.CanHandleData).ToList();
processors = _listeners.Where(s => s.ListenerIdentifiers.Contains(listenId)).ToList();
if (processors.Count == 0)
{
@@ -451,7 +456,7 @@ namespace CryptoExchange.Net.Sockets
{
List<string> listenerIds;
lock (_listenersLock)
listenerIds = _listeners.Where(l => l.CanHandleData).SelectMany(l => l.ListenerIdentifiers).ToList();
listenerIds = _listeners.SelectMany(l => l.ListenerIdentifiers).ToList();
_logger.ReceivedMessageNotMatchedToAnyListener(SocketId, listenId, string.Join(",", listenerIds));
UnhandledMessage?.Invoke(_accessor);
}
@@ -478,6 +483,10 @@ namespace CryptoExchange.Net.Sockets
continue;
}
if (processor is Subscription subscriptionProcessor && !subscriptionProcessor.Confirmed)
// If this message is for this listener then it is automatically confirmed, even if the subscription is not (yet) confirmed
subscriptionProcessor.Confirmed = true;
// 6. Deserialize the message
object? deserialized = null;
desCache?.TryGetValue(messageType, out deserialized);
@@ -498,7 +507,9 @@ namespace CryptoExchange.Net.Sockets
try
{
var innerSw = Stopwatch.StartNew();
processor.Handle(this, new DataEvent<object>(deserialized, null, originalData, receiveTime, null));
await processor.Handle(this, new DataEvent<object>(deserialized, null, null, originalData, receiveTime, null)).ConfigureAwait(false);
if (processor is Query query && query.RequiredResponses != 1)
_logger.LogDebug($"[Sckt {SocketId}] [Req {query.Id}] responses: {query.CurrentResponses}/{query.RequiredResponses}");
totalUserTime += (int)innerSw.ElapsedMilliseconds;
}
catch (Exception ex)
@@ -564,9 +575,8 @@ namespace CryptoExchange.Net.Sockets
/// Close a subscription on this connection. If all subscriptions on this connection are closed the connection gets closed as well
/// </summary>
/// <param name="subscription">Subscription to close</param>
/// <param name="unsubEvenIfNotConfirmed">Whether to send an unsub request even if the subscription wasn't confirmed</param>
/// <returns></returns>
public async Task CloseAsync(Subscription subscription, bool unsubEvenIfNotConfirmed = false)
public async Task CloseAsync(Subscription subscription)
{
subscription.Closed = true;
@@ -587,7 +597,7 @@ namespace CryptoExchange.Net.Sockets
lock (_listenersLock)
needUnsub = _listeners.Contains(subscription);
if (needUnsub && (unsubEvenIfNotConfirmed || subscription.Confirmed) && _socket.IsOpen)
if (needUnsub && _socket.IsOpen)
await UnsubscribeAsync(subscription).ConfigureAwait(false);
}
else
@@ -604,7 +614,7 @@ namespace CryptoExchange.Net.Sockets
bool shouldCloseConnection;
lock (_listenersLock)
{
shouldCloseConnection = _listeners.OfType<Subscription>().All(r => !r.UserSubscription || r.Closed);
shouldCloseConnection = _listeners.OfType<Subscription>().All(r => !r.UserSubscription || r.Closed) && !DedicatedRequestConnection;
if (shouldCloseConnection)
Status = SocketStatus.Closing;
}
@@ -686,27 +696,30 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
/// <param name="query">Query to send</param>
/// <param name="continueEvent">Wait event for when the socket message handler can continue</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public virtual async Task<CallResult> SendAndWaitQueryAsync(Query query, ManualResetEvent? continueEvent = null)
public virtual async Task<CallResult> SendAndWaitQueryAsync(Query query, AsyncResetEvent? continueEvent = null, CancellationToken ct = default)
{
await SendAndWaitIntAsync(query, continueEvent).ConfigureAwait(false);
await SendAndWaitIntAsync(query, continueEvent, ct).ConfigureAwait(false);
return query.Result ?? new CallResult(new ServerError("Timeout"));
}
/// <summary>
/// Send a query request and wait for an answer
/// </summary>
/// <typeparam name="T">Query response type</typeparam>
/// <typeparam name="THandlerResponse">Expected result type</typeparam>
/// <typeparam name="TServerResponse">The type returned to the caller</typeparam>
/// <param name="query">Query to send</param>
/// <param name="continueEvent">Wait event for when the socket message handler can continue</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public virtual async Task<CallResult<T>> SendAndWaitQueryAsync<T>(Query<T> query, ManualResetEvent? continueEvent = null)
public virtual async Task<CallResult<THandlerResponse>> SendAndWaitQueryAsync<TServerResponse, THandlerResponse>(Query<TServerResponse, THandlerResponse> query, AsyncResetEvent? continueEvent = null, CancellationToken ct = default)
{
await SendAndWaitIntAsync(query, continueEvent).ConfigureAwait(false);
return query.TypedResult ?? new CallResult<T>(new ServerError("Timeout"));
await SendAndWaitIntAsync(query, continueEvent, ct).ConfigureAwait(false);
return query.TypedResult ?? new CallResult<THandlerResponse>(new ServerError("Timeout"));
}
private async Task SendAndWaitIntAsync(Query query, ManualResetEvent? continueEvent)
private async Task SendAndWaitIntAsync(Query query, AsyncResetEvent? continueEvent, CancellationToken ct = default)
{
lock(_listenersLock)
_listeners.Add(query);
@@ -723,7 +736,7 @@ namespace CryptoExchange.Net.Sockets
try
{
while (true)
while (!ct.IsCancellationRequested)
{
if (!_socket.IsOpen)
{
@@ -734,11 +747,17 @@ namespace CryptoExchange.Net.Sockets
if (query.Completed)
return;
await query.WaitAsync(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false);
await query.WaitAsync(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false);
if (query.Completed)
return;
}
if (ct.IsCancellationRequested)
{
query.Fail(new CancellationRequestedError());
return;
}
}
finally
{
@@ -784,7 +803,9 @@ namespace CryptoExchange.Net.Sockets
_logger.SendingData(SocketId, requestId, data);
try
{
_socket.Send(requestId, data, weight);
if (!_socket.Send(requestId, data, weight))
return new CallResult(new WebError("Failed to send message, connection not open"));
return new CallResult(null);
}
catch(Exception ex)
@@ -798,20 +819,27 @@ namespace CryptoExchange.Net.Sockets
if (!_socket.IsOpen)
return new CallResult(new WebError("Socket not connected"));
bool anySubscriptions;
lock (_listenersLock)
anySubscriptions = _listeners.OfType<Subscription>().Any(s => s.UserSubscription);
if (!anySubscriptions)
if (!DedicatedRequestConnection)
{
// No need to resubscribe anything
_logger.NothingToResubscribeCloseConnection(SocketId);
_ = _socket.CloseAsync();
return new CallResult(null);
bool anySubscriptions;
lock (_listenersLock)
anySubscriptions = _listeners.OfType<Subscription>().Any(s => s.UserSubscription);
if (!anySubscriptions)
{
// No need to resubscribe anything
_logger.NothingToResubscribeCloseConnection(SocketId);
_ = _socket.CloseAsync();
return new CallResult(null);
}
}
bool anyAuthenticated;
lock (_listenersLock)
anyAuthenticated = _listeners.OfType<Subscription>().Any(s => s.Authenticated);
{
anyAuthenticated = _listeners.OfType<Subscription>().Any(s => s.Authenticated)
|| (DedicatedRequestConnection && ApiClient.AuthenticationProvider != null);
}
if (anyAuthenticated)
{
// If we reconnected a authenticated connection we need to re-authenticate
@@ -826,40 +854,43 @@ namespace CryptoExchange.Net.Sockets
_logger.AuthenticationSucceeded(SocketId);
}
// Get a list of all subscriptions on the socket
List<Subscription> subList;
lock (_listenersLock)
subList = _listeners.OfType<Subscription>().ToList();
foreach(var subscription in subList)
{
subscription.ConnectionInvocations = 0;
var result = await ApiClient.RevitalizeRequestAsync(subscription).ConfigureAwait(false);
if (!result)
{
_logger.FailedRequestRevitalization(SocketId, result.Error?.ToString());
return result;
}
}
// Foreach subscription which is subscribed by a subscription request we will need to resend that request to resubscribe
for (var i = 0; i < subList.Count; i += ApiClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket)
int batch = 0;
int batchSize = ApiClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket;
while (true)
{
if (!_socket.IsOpen)
return new CallResult(new WebError("Socket not connected"));
List<Subscription> subList;
lock (_listenersLock)
subList = _listeners.OfType<Subscription>().Skip(batch * batchSize).Take(batchSize).ToList();
if (subList.Count == 0)
break;
var taskList = new List<Task<CallResult>>();
foreach (var subscription in subList.Skip(i).Take(ApiClient.ClientOptions.MaxConcurrentResubscriptionsPerSocket))
foreach (var subscription in subList)
{
subscription.ConnectionInvocations = 0;
var result = await ApiClient.RevitalizeRequestAsync(subscription).ConfigureAwait(false);
if (!result)
{
_logger.FailedRequestRevitalization(SocketId, result.Error?.ToString());
return result;
}
var subQuery = subscription.GetSubQuery(this);
if (subQuery == null)
continue;
var waitEvent = new ManualResetEvent(false);
var waitEvent = new AsyncResetEvent(false);
taskList.Add(SendAndWaitQueryAsync(subQuery, waitEvent).ContinueWith((r) =>
{
subscription.HandleSubQueryResponse(subQuery.Response!);
waitEvent.Set();
if (r.Result.Success)
subscription.Confirmed = true;
return r.Result;
}));
}
@@ -867,10 +898,9 @@ namespace CryptoExchange.Net.Sockets
await Task.WhenAll(taskList).ConfigureAwait(false);
if (taskList.Any(t => !t.Result.Success))
return taskList.First(t => !t.Result.Success).Result;
}
foreach (var subscription in subList)
subscription.Confirmed = true;
batch++;
}
if (!_socket.IsOpen)
return new CallResult(new WebError("Socket not connected"));
+3 -12
View File
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Sockets
{
@@ -18,11 +19,6 @@ namespace CryptoExchange.Net.Sockets
/// </summary>
public int Id { get; set; }
/// <summary>
/// Can handle data
/// </summary>
public bool CanHandleData => Confirmed || HandleUpdatesBeforeConfirmation;
/// <summary>
/// Total amount of invocations
/// </summary>
@@ -42,11 +38,6 @@ namespace CryptoExchange.Net.Sockets
/// Has the subscription been confirmed
/// </summary>
public bool Confirmed { get; set; }
/// <summary>
/// Whether this subscription should handle update messages before confirmation
/// </summary>
public bool HandleUpdatesBeforeConfirmation { get; set; }
/// <summary>
/// Is the subscription closed
@@ -132,11 +123,11 @@ namespace CryptoExchange.Net.Sockets
/// <param name="connection"></param>
/// <param name="message"></param>
/// <returns></returns>
public CallResult Handle(SocketConnection connection, DataEvent<object> message)
public Task<CallResult> Handle(SocketConnection connection, DataEvent<object> message)
{
ConnectionInvocations++;
TotalInvocations++;
return DoHandleMessage(connection, message);
return Task.FromResult(DoHandleMessage(connection, message));
}
/// <summary>
@@ -0,0 +1,387 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json.Nodes;
using CryptoExchange.Net.Converters;
using CryptoExchange.Net.Converters.JsonNet;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net.Testing.Comparers
{
internal class JsonNetComparer
{
internal static void CompareData(
string method,
object resultData,
string json,
string? nestedJsonProperty,
List<string>? ignoreProperties = null,
bool userSingleArrayItem = false)
{
var resultProperties = resultData.GetType().GetProperties().Select(p => (p, (JsonPropertyAttribute?)p.GetCustomAttributes(typeof(JsonPropertyAttribute), true).SingleOrDefault()));
var jsonObject = JToken.Parse(json);
if (nestedJsonProperty != null)
{
var nested = nestedJsonProperty.Split('.');
foreach (var nest in nested)
{
if (int.TryParse(nest, out var index))
jsonObject = jsonObject![index];
else
jsonObject = jsonObject![nest];
}
}
if (userSingleArrayItem)
jsonObject = ((JArray)jsonObject!)[0];
if (resultData.GetType().GetInterfaces().Contains(typeof(IDictionary)))
{
var dict = (IDictionary)resultData;
var jObj = (JObject)jsonObject!;
var properties = jObj.Properties();
foreach (var dictProp in properties)
{
if (!dict.Contains(dictProp.Name))
throw new Exception($"{method}: Dictionary has no value for {dictProp.Name} while input json `{dictProp.Name}` has value {dictProp.Value}");
if (dictProp.Value.Type == JTokenType.Object)
{
// TODO Some additional checking for objects
foreach (var prop in ((JObject)dictProp.Value).Properties())
CheckObject(method, prop, dict[dictProp.Name]!, ignoreProperties!);
}
else
{
if (dict[dictProp.Name] == default && dictProp.Value.Type != JTokenType.Null)
// Property value not correct
throw new Exception($"{method}: Dictionary entry `{dictProp.Name}` has no value while input json has value {dictProp.Value}");
}
}
}
else if (jsonObject!.Type == JTokenType.Array)
{
var jObjs = (JArray)jsonObject;
if (resultData is IEnumerable list)
{
var enumerator = list.GetEnumerator();
foreach (var jObj in jObjs)
{
enumerator.MoveNext();
if (jObj.Type == JTokenType.Object)
{
foreach (var subProp in ((JObject)jObj).Properties())
{
if (ignoreProperties?.Contains(subProp.Name) == true)
continue;
CheckObject(method, subProp, enumerator.Current, ignoreProperties!);
}
}
else if (jObj.Type == JTokenType.Array)
{
var resultObj = enumerator.Current;
if (resultObj is string)
// string list
continue;
var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
var arrayConverterProperty = resultObj.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true).FirstOrDefault();
var jsonConverter = ((JsonConverterAttribute)arrayConverterProperty!).ConverterType;
if (jsonConverter != typeof(ArrayConverter))
// Not array converter?
continue;
int i = 0;
foreach (var item in jObj.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultObj), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
else
{
var value = enumerator.Current;
if (value == default && ((JValue)jObj).Type != JTokenType.Null)
throw new Exception($"{method}: Array has no value while input json array has value {jObj}");
}
}
}
else
{
var resultProps = resultData.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
int i = 0;
foreach (var item in jObjs.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultData), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
}
else
{
foreach (var item in jsonObject)
{
if (item is JProperty prop)
{
if (ignoreProperties?.Contains(prop.Name) == true)
continue;
CheckObject(method, prop, resultData, ignoreProperties);
}
}
}
Debug.WriteLine($"Successfully validated {method}");
}
private static void CheckObject(string method, JProperty prop, object obj, List<string>? ignoreProperties)
{
var resultProperties = obj.GetType().GetProperties().Select(p => (p, ((JsonPropertyAttribute?)p.GetCustomAttributes(typeof(JsonPropertyAttribute), true).SingleOrDefault())?.PropertyName));
// Property has a value
var property = resultProperties.SingleOrDefault(p => p.PropertyName == prop.Name).p;
property ??= resultProperties.SingleOrDefault(p => p.p.Name == prop.Name).p;
property ??= resultProperties.SingleOrDefault(p => p.p.Name.Equals(prop.Name, StringComparison.InvariantCultureIgnoreCase)).p;
if (property is null)
// Property not found
throw new Exception($"{method}: Missing property `{prop.Name}` on `{obj.GetType().Name}`");
var propertyValue = property.GetValue(obj);
if (property.GetCustomAttribute<JsonPropertyAttribute>(true)?.ItemConverterType == null)
CheckPropertyValue(method, prop.Value, propertyValue, property.PropertyType, property.Name, prop.Name, ignoreProperties);
}
private static void CheckPropertyValue(string method, JToken propValue, object? propertyValue, Type propertyType, string? propertyName = null, string? propName = null, List<string>? ignoreProperties = null)
{
if (propertyValue == default && propValue.Type != JTokenType.Null && !string.IsNullOrEmpty(propValue.ToString()))
{
if (propertyType == typeof(DateTime?) && (propValue.ToString() == "" || propValue.ToString() == "0" || propValue.ToString() == "-1"))
return;
// Property value not correct
if (propValue.ToString() != "0")
throw new Exception($"{method}: Property `{propertyName}` has no value while input json `{propName}` has value {propValue}");
}
if (propertyValue == default && (propValue.Type == JTokenType.Null || string.IsNullOrEmpty(propValue.ToString())) || propValue.ToString() == "0")
return;
if (propertyValue!.GetType().GetInterfaces().Contains(typeof(IDictionary)))
{
var dict = (IDictionary)propertyValue;
var jObj = (JObject)propValue;
var properties = jObj.Properties();
foreach (var dictProp in properties)
{
if (!dict.Contains(dictProp.Name))
throw new Exception($"{method}: Property `{propertyName}` has no value while input json `{propName}` has value {propValue}");
if (dictProp.Value.Type == JTokenType.Object)
{
CheckObject(method, dictProp, dict[dictProp.Name]!, ignoreProperties);
}
else
{
if (dict[dictProp.Name] == default && dictProp.Value.Type != JTokenType.Null)
// Property value not correct
throw new Exception($"{method}: Dictionary entry `{dictProp.Name}` has no value while input json has value {propValue} for");
}
}
}
else if (propertyValue.GetType().GetInterfaces().Contains(typeof(IEnumerable))
&& propertyValue.GetType() != typeof(string))
{
var jObjs = (JArray)propValue;
var list = (IEnumerable)propertyValue;
var enumerator = list.GetEnumerator();
foreach (JToken jtoken in jObjs)
{
enumerator.MoveNext();
var typeConverter = enumerator.Current.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true);
if (typeConverter.Length != 0 && ((JsonConverterAttribute)typeConverter.First()).ConverterType != typeof(ArrayConverter))
// Custom converter for the type, skip
continue;
if (jtoken.Type == JTokenType.Object)
{
foreach (var subProp in ((JObject)jtoken).Properties())
{
if (ignoreProperties?.Contains(subProp.Name) == true)
continue;
CheckObject(method, subProp, enumerator.Current, ignoreProperties);
}
}
else if (jtoken.Type == JTokenType.Array)
{
var resultObj = enumerator.Current;
var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
var arrayConverterProperty = resultObj.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true).FirstOrDefault();
var jsonConverter = ((JsonConverterAttribute)arrayConverterProperty!).ConverterType;
if (jsonConverter != typeof(ArrayConverter))
// Not array converter?
continue;
int i = 0;
foreach (var item in jtoken.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultObj), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
else
{
var value = enumerator.Current;
if (value == default && ((JValue)jtoken).Type != JTokenType.Null)
throw new Exception($"{method}: Property `{propertyName}` has no value while input json `{propName}` has value {jtoken}");
CheckValues(method, propertyName!, propertyType, (JValue)jtoken, value!);
}
}
}
else
{
if (propValue.Type == JTokenType.Object)
{
foreach (var item in propValue)
{
if (item is JProperty prop)
{
if (ignoreProperties?.Contains(prop.Name) == true)
continue;
CheckObject(method, prop, propertyValue, ignoreProperties);
}
}
}
else if(propValue.Type == JTokenType.Array)
{
var jObjs = (JArray)propValue;
if (propertyValue is IEnumerable list)
{
var enumerator = list.GetEnumerator();
foreach (var jObj in jObjs)
{
if (!enumerator.MoveNext())
{
}
if (jObj.Type == JTokenType.Object)
{
foreach (var subProp in ((JObject)jObj).Properties())
{
if (ignoreProperties?.Contains(subProp.Name) == true)
continue;
CheckObject(method, subProp, enumerator.Current, ignoreProperties!);
}
}
else if (jObj.Type == JTokenType.Array)
{
var resultObj = enumerator.Current;
var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
var arrayConverterProperty = resultObj.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true).FirstOrDefault();
var jsonConverter = ((JsonConverterAttribute)arrayConverterProperty!).ConverterType;
if (jsonConverter != typeof(ArrayConverter))
// Not array converter?
continue;
int i = 0;
foreach (var item in jObj.Values())
{
var arrayProp = resultProps.SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultObj), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
else
{
var value = enumerator.Current;
if (value == default && ((JValue)jObj).Type != JTokenType.Null)
throw new Exception($"{method}: Array has no value while input json array has value {jObj}");
}
}
}
else
{
var resultProps = propertyValue.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
int i = 0;
foreach (var item in jObjs.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(propertyValue), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
}
else
{
CheckValues(method, propertyName!, propertyType, (JValue)propValue, propertyValue);
}
}
}
private static void CheckValues(string method, string property, Type propertyType, JValue jsonValue, object objectValue)
{
if (jsonValue.Type == JTokenType.String)
{
if (objectValue is decimal dec)
{
if (jsonValue.Value<decimal>() != dec)
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<decimal>()} vs {dec}");
}
else if (objectValue is DateTime time)
{
if (time != DateTimeConverter.ParseFromString(jsonValue.Value<string>()!))
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<decimal>()} vs {time}");
}
else if (propertyType.IsEnum || Nullable.GetUnderlyingType(propertyType)?.IsEnum == true)
{
// TODO enum comparing
}
else if (!jsonValue.Value<string>()!.Equals(Convert.ToString(objectValue, CultureInfo.InvariantCulture), StringComparison.InvariantCultureIgnoreCase))
{
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<string>()} vs {objectValue}");
}
}
else if (jsonValue.Type == JTokenType.Integer)
{
if (objectValue is DateTime time)
{
if (time != DateTimeConverter.ParseFromLong(jsonValue.Value<long>()!))
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<decimal>()} vs {time}");
}
else if (propertyType.IsEnum)
{
// TODO enum comparing
}
else if (jsonValue.Value<long>() != Convert.ToInt64(objectValue))
{
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<long>()} vs {Convert.ToInt64(objectValue)}");
}
}
else if (jsonValue.Type == JTokenType.Boolean)
{
if (objectValue is bool boolVal && jsonValue.Value<bool>() != boolVal)
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<bool>()} vs {(bool)objectValue}");
if (jsonValue.Value<bool>() != bool.Parse(objectValue.ToString()))
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<bool>()} vs {(bool)objectValue}");
}
}
}
}
@@ -0,0 +1,392 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using CryptoExchange.Net.Converters;
using CryptoExchange.Net.Converters.SystemTextJson;
using Newtonsoft.Json.Linq;
namespace CryptoExchange.Net.Testing.Comparers
{
internal class SystemTextJsonComparer
{
internal static void CompareData(
string method,
object resultData,
string json,
string? nestedJsonProperty,
List<string>? ignoreProperties = null,
bool userSingleArrayItem = false)
{
var resultProperties = resultData.GetType().GetProperties().Select(p => (p, (JsonPropertyNameAttribute?)p.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).SingleOrDefault()));
var jsonObject = JToken.Parse(json);
if (nestedJsonProperty != null)
{
var nested = nestedJsonProperty.Split('.');
foreach (var nest in nested)
{
if (int.TryParse(nest, out var index))
jsonObject = jsonObject![index];
else
jsonObject = jsonObject![nest];
}
}
if (userSingleArrayItem)
jsonObject = ((JArray)jsonObject!)[0];
if (resultData.GetType().GetInterfaces().Contains(typeof(IDictionary)))
{
var dict = (IDictionary)resultData;
var jObj = (JObject)jsonObject!;
var properties = jObj.Properties();
foreach (var dictProp in properties)
{
if (!dict.Contains(dictProp.Name))
throw new Exception($"{method}: Dictionary has no value for {dictProp.Name} while input json `{dictProp.Name}` has value {dictProp.Value}");
if (dictProp.Value.Type == JTokenType.Object)
{
// TODO Some additional checking for objects
foreach (var prop in ((JObject)dictProp.Value).Properties())
CheckObject(method, prop, dict[dictProp.Name]!, ignoreProperties!);
}
else
{
if (dict[dictProp.Name] == default && dictProp.Value.Type != JTokenType.Null)
{
if (dictProp.Value.ToString() == "")
continue;
// Property value not correct
throw new Exception($"{method}: Dictionary entry `{dictProp.Name}` has no value while input json has value {dictProp.Value}");
}
}
}
}
else if (jsonObject!.Type == JTokenType.Array)
{
var jObjs = (JArray)jsonObject;
if (resultData is IEnumerable list)
{
var enumerator = list.GetEnumerator();
foreach (var jObj in jObjs)
{
if (!enumerator.MoveNext())
{
}
if (jObj.Type == JTokenType.Object)
{
foreach (var subProp in ((JObject)jObj).Properties())
{
if (ignoreProperties?.Contains(subProp.Name) == true)
continue;
CheckObject(method, subProp, enumerator.Current, ignoreProperties!);
}
}
else if (jObj.Type == JTokenType.Array)
{
var resultObj = enumerator.Current;
if (resultObj is string)
// string list
continue;
var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
var arrayConverterProperty = resultObj.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true).FirstOrDefault();
var jsonConverter = ((JsonConverterAttribute)arrayConverterProperty!).ConverterType;
if (jsonConverter != typeof(ArrayConverter))
// Not array converter?
continue;
int i = 0;
foreach (var item in jObj.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultObj), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
else
{
var value = enumerator.Current;
if (value == default && ((JValue)jObj).Type != JTokenType.Null)
throw new Exception($"{method}: Array has no value while input json array has value {jObj}");
}
}
}
else
{
var resultProps = resultData.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
int i = 0;
foreach (var item in jObjs.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultData), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
}
else
{
foreach (var item in jsonObject)
{
if (item is JProperty prop)
{
if (ignoreProperties?.Contains(prop.Name) == true)
continue;
CheckObject(method, prop, resultData, ignoreProperties);
}
}
}
Debug.WriteLine($"Successfully validated {method}");
}
private static void CheckObject(string method, JProperty prop, object obj, List<string>? ignoreProperties)
{
var resultProperties = obj.GetType().GetProperties().Select(p => (p, ((JsonPropertyNameAttribute?)p.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true).SingleOrDefault())?.Name));
// Property has a value
var property = resultProperties.SingleOrDefault(p => p.Name == prop.Name).p;
property ??= resultProperties.SingleOrDefault(p => p.p.Name == prop.Name).p;
property ??= resultProperties.SingleOrDefault(p => p.p.Name.Equals(prop.Name, StringComparison.InvariantCultureIgnoreCase)).p;
if (property is null)
// Property not found
throw new Exception($"{method}: Missing property `{prop.Name}` on `{obj.GetType().Name}`");
var propertyValue = property.GetValue(obj);
CheckPropertyValue(method, prop.Value, propertyValue, property.PropertyType, property.Name, prop.Name, ignoreProperties);
}
private static void CheckPropertyValue(string method, JToken propValue, object? propertyValue, Type propertyType, string? propertyName = null, string? propName = null, List<string>? ignoreProperties = null)
{
if (propertyValue == default && propValue.Type != JTokenType.Null && !string.IsNullOrEmpty(propValue.ToString()))
{
if (propertyType == typeof(DateTime?) && (propValue.ToString() == "" || propValue.ToString() == "0" || propValue.ToString() == "-1"))
return;
// Property value not correct
if (propValue.ToString() != "0")
throw new Exception($"{method}: Property `{propertyName}` has no value while input json `{propName}` has value {propValue}");
}
if (propertyValue == default && (propValue.Type == JTokenType.Null || string.IsNullOrEmpty(propValue.ToString())) || propValue.ToString() == "0")
return;
if (propertyValue!.GetType().GetInterfaces().Contains(typeof(IDictionary)))
{
var dict = (IDictionary)propertyValue;
var jObj = (JObject)propValue;
var properties = jObj.Properties();
foreach (var dictProp in properties)
{
if (!dict.Contains(dictProp.Name))
throw new Exception($"{method}: Property `{propertyName}` has no value while input json `{propName}` has value {propValue}");
if (dictProp.Value.Type == JTokenType.Object)
{
CheckPropertyValue(method, dictProp.Value, dict[dictProp.Name]!, dict[dictProp.Name].GetType(), null, null, ignoreProperties);
}
else
{
if (dict[dictProp.Name] == default && dictProp.Value.Type != JTokenType.Null)
// Property value not correct
throw new Exception($"{method}: Dictionary entry `{dictProp.Name}` has no value while input json has value {propValue} for");
}
}
}
else if (propertyValue.GetType().GetInterfaces().Contains(typeof(IEnumerable))
&& propertyValue.GetType() != typeof(string))
{
var jObjs = (JArray)propValue;
var list = (IEnumerable)propertyValue;
var enumerator = list.GetEnumerator();
foreach (JToken jtoken in jObjs)
{
var moved = enumerator.MoveNext();
if (!moved)
throw new Exception("Enumeration not moved; incorrect amount of results?");
var typeConverter = enumerator.Current.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true);
if (typeConverter.Length != 0 && ((JsonConverterAttribute)typeConverter.First()).ConverterType != typeof(ArrayConverter))
// Custom converter for the type, skip
continue;
if (jtoken.Type == JTokenType.Object)
{
foreach (var subProp in ((JObject)jtoken).Properties())
{
if (ignoreProperties?.Contains(subProp.Name) == true)
continue;
CheckObject(method, subProp, enumerator.Current, ignoreProperties);
}
}
else if (jtoken.Type == JTokenType.Array)
{
var resultObj = enumerator.Current;
var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
var arrayConverterProperty = resultObj.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true).FirstOrDefault();
var jsonConverter = ((JsonConverterAttribute)arrayConverterProperty!).ConverterType;
if (jsonConverter != typeof(ArrayConverter))
// Not array converter?
continue;
int i = 0;
foreach (var item in jtoken.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultObj), propertyType, arrayProp.Name, "Array index " + i, ignoreProperties);
i++;
}
}
else
{
var value = enumerator.Current;
if (value == default && ((JValue)jtoken).Type != JTokenType.Null)
throw new Exception($"{method}: Property `{propertyName}` has no value while input json `{propName}` has value {jtoken}");
CheckValues(method, propertyName!, propertyType, (JValue)jtoken, value!);
}
}
}
else
{
if (propValue.Type == JTokenType.Object)
{
foreach (var item in propValue)
{
if (item is JProperty prop)
{
if (ignoreProperties?.Contains(prop.Name) == true)
continue;
CheckObject(method, prop, propertyValue, ignoreProperties);
}
}
}
else if (propValue.Type == JTokenType.Array)
{
var jObjs = (JArray)propValue;
if (propertyValue is IEnumerable list)
{
var enumerator = list.GetEnumerator();
foreach (var jObj in jObjs)
{
if (!enumerator.MoveNext())
{
}
if (jObj.Type == JTokenType.Object)
{
foreach (var subProp in ((JObject)jObj).Properties())
{
if (ignoreProperties?.Contains(subProp.Name) == true)
continue;
CheckObject(method, subProp, enumerator.Current, ignoreProperties!);
}
}
else if (jObj.Type == JTokenType.Array)
{
var resultObj = enumerator.Current;
var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
var arrayConverterProperty = resultObj.GetType().GetCustomAttributes(typeof(JsonConverterAttribute), true).FirstOrDefault();
var jsonConverter = ((JsonConverterAttribute)arrayConverterProperty!).ConverterType;
if (jsonConverter != typeof(ArrayConverter))
// Not array converter?
continue;
int i = 0;
foreach (var item in jObj.Values())
{
var arrayProp = resultProps.SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(resultObj), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
else
{
var value = enumerator.Current;
if (value == default && ((JValue)jObj).Type != JTokenType.Null)
throw new Exception($"{method}: Array has no value while input json array has value {jObj}");
}
}
}
else
{
var resultProps = propertyValue.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast<ArrayPropertyAttribute>().SingleOrDefault()));
int i = 0;
foreach (var item in jObjs.Children())
{
var arrayProp = resultProps.Where(p => p.Item2 != null).SingleOrDefault(p => p.Item2!.Index == i).p;
if (arrayProp != null)
CheckPropertyValue(method, item, arrayProp.GetValue(propertyValue), arrayProp.PropertyType, arrayProp.Name, "Array index " + i, ignoreProperties!);
i++;
}
}
}
else
{
CheckValues(method, propertyName!, propertyType, (JValue)propValue, propertyValue);
}
}
}
private static void CheckValues(string method, string property, Type propertyType, JValue jsonValue, object objectValue)
{
if (jsonValue.Type == JTokenType.String)
{
if (objectValue is decimal dec)
{
if (jsonValue.Value<decimal>() != dec)
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<decimal>()} vs {dec}");
}
else if (objectValue is DateTime time)
{
if (time != DateTimeConverter.ParseFromString(jsonValue.Value<string>()!))
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<string>()} vs {time}");
}
else if (propertyType.IsEnum || Nullable.GetUnderlyingType(propertyType)?.IsEnum == true)
{
// TODO enum comparing
}
else if (!jsonValue.Value<string>()!.Equals(Convert.ToString(objectValue, CultureInfo.InvariantCulture), StringComparison.InvariantCultureIgnoreCase))
{
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<string>()} vs {objectValue}");
}
}
else if (jsonValue.Type == JTokenType.Integer)
{
if (objectValue is DateTime time)
{
if (time != DateTimeConverter.ParseFromDouble(jsonValue.Value<long>()!))
throw new Exception($"{method}: {property} not equal: {DateTimeConverter.ParseFromDouble(jsonValue.Value<long>()!)} vs {time}");
}
else if (propertyType.IsEnum || Nullable.GetUnderlyingType(propertyType)?.IsEnum == true)
{
// TODO enum comparing
}
else if (jsonValue.Value<long>() != Convert.ToInt64(objectValue))
{
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<long>()} vs {Convert.ToInt64(objectValue)}");
}
}
else if (jsonValue.Type == JTokenType.Boolean)
{
if (jsonValue.Value<bool>() != (bool)objectValue)
throw new Exception($"{method}: {property} not equal: {jsonValue.Value<bool>()} vs {(bool)objectValue}");
}
}
}
}
@@ -0,0 +1,20 @@
using System;
using System.Diagnostics;
namespace CryptoExchange.Net.Testing
{
internal class EnumValueTraceListener : TraceListener
{
public override void Write(string message)
{
if (message.Contains("Cannot map"))
throw new Exception("Enum value error: " + message);
}
public override void WriteLine(string message)
{
if (message.Contains("Cannot map"))
throw new Exception("Enum value error: " + message);
}
}
}
@@ -0,0 +1,17 @@
using CryptoExchange.Net.Interfaces;
using System;
namespace CryptoExchange.Net.Testing.Implementations
{
internal class TestAuthTimeProvider : IAuthTimeProvider
{
private readonly DateTime _timestamp;
public TestAuthTimeProvider(DateTime timestamp)
{
_timestamp = timestamp;
}
public DateTime GetTime() => _timestamp;
}
}
@@ -0,0 +1,23 @@
using CryptoExchange.Net.Interfaces;
namespace CryptoExchange.Net.Testing.Implementations
{
/// <summary>
/// Test implementation for nonce provider, returning a prespecified nonce
/// </summary>
public class TestNonceProvider : INonceProvider
{
private readonly long _nonce;
/// <summary>
/// ctor
/// </summary>
public TestNonceProvider(long nonce)
{
_nonce = nonce;
}
/// <inheritdoc />
public long GetNonce() => _nonce;
}
}
@@ -0,0 +1,52 @@
using CryptoExchange.Net.Interfaces;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Testing.Implementations
{
internal class TestRequest : IRequest
{
private readonly Dictionary<string, IEnumerable<string>> _headers = new Dictionary<string, IEnumerable<string>>();
private readonly TestResponse _response;
public string Accept { set { } }
public string? Content { get; private set; }
public HttpMethod Method { get; set; }
public Uri Uri { get; set; }
public int RequestId { get; set; }
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public TestRequest(TestResponse response)
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
{
_response = response;
}
public void AddHeader(string key, string value)
{
_headers.Add(key, new[] { value });
}
public Dictionary<string, IEnumerable<string>> GetHeaders() => _headers;
public Task<IResponse> GetResponseAsync(CancellationToken cancellationToken) => Task.FromResult<IResponse>(_response);
public void SetContent(byte[] data)
{
Content = Encoding.UTF8.GetString(data);
}
public void SetContent(string data, string contentType)
{
Content = data;
}
}
}
@@ -0,0 +1,29 @@
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using System;
using System.Net.Http;
namespace CryptoExchange.Net.Testing.Implementations
{
internal class TestRequestFactory : IRequestFactory
{
private readonly TestRequest _request;
public TestRequestFactory(TestRequest request)
{
_request = request;
}
public void Configure(ApiProxy? proxy, TimeSpan requestTimeout, HttpClient? httpClient = null)
{
}
public IRequest Create(HttpMethod method, Uri uri, int requestId)
{
_request.Method = method;
_request.Uri = uri;
_request.RequestId = requestId;
return _request;
}
}
}
@@ -0,0 +1,34 @@
using CryptoExchange.Net.Interfaces;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Testing.Implementations
{
internal class TestResponse : IResponse
{
private readonly Stream _response;
public HttpStatusCode StatusCode { get; }
public bool IsSuccessStatusCode { get; }
public long? ContentLength { get; }
public IEnumerable<KeyValuePair<string, IEnumerable<string>>> ResponseHeaders { get; } = new Dictionary<string, IEnumerable<string>>();
public TestResponse(HttpStatusCode code, Stream response)
{
StatusCode = code;
IsSuccessStatusCode = code == HttpStatusCode.OK;
_response = response;
}
public void Close()
{
}
public Task<Stream> GetResponseStreamAsync() => Task.FromResult(_response);
}
}
@@ -0,0 +1,95 @@
using System;
using System.Net.WebSockets;
using System.Text;
using System.Threading.Tasks;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects;
using Newtonsoft.Json;
namespace CryptoExchange.Net.Testing.Implementations
{
internal class TestSocket : IWebsocket
{
public event Action<string>? OnMessageSend;
public bool CanConnect { get; set; } = true;
public bool Connected { get; set; }
public event Func<Task>? OnClose;
#pragma warning disable 0067
public event Func<Task>? OnReconnected;
public event Func<Task>? OnReconnecting;
public event Func<int, Task>? OnRequestRateLimited;
public event Func<Exception, Task>? OnError;
#pragma warning restore 0067
public event Func<int, Task>? OnRequestSent;
public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task>? OnStreamMessage;
public event Func<Task>? OnOpen;
public int Id { get; }
public bool IsClosed => !Connected;
public bool IsOpen => Connected;
public double IncomingKbps => 0;
public Uri Uri { get; set; }
public Func<Task<Uri?>>? GetReconnectionUrl { get; set; }
public static int lastId = 0;
public static object lastIdLock = new object();
public TestSocket(string address)
{
Uri = new Uri(address);
lock (lastIdLock)
{
Id = lastId + 1;
lastId++;
}
}
public Task<CallResult> ConnectAsync()
{
Connected = CanConnect;
return Task.FromResult(CanConnect ? new CallResult(null) : new CallResult(new CantConnectError()));
}
public bool Send(int requestId, string data, int weight)
{
if (!Connected)
throw new Exception("Socket not connected");
OnRequestSent?.Invoke(requestId);
OnMessageSend?.Invoke(data);
return true;
}
public Task CloseAsync()
{
Connected = false;
return Task.FromResult(0);
}
public void InvokeClose()
{
Connected = false;
OnClose?.Invoke();
}
public void InvokeOpen()
{
OnOpen?.Invoke();
}
public void InvokeMessage(string data)
{
OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes(data))).Wait();
}
public void InvokeMessage<T>(T data)
{
OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(data)))).Wait();
}
public Task ReconnectAsync() => throw new NotImplementedException();
public void Dispose() { }
}
}
@@ -0,0 +1,17 @@
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Objects.Sockets;
using Microsoft.Extensions.Logging;
namespace CryptoExchange.Net.Testing.Implementations
{
internal class TestWebsocketFactory : IWebsocketFactory
{
private readonly TestSocket _socket;
public TestWebsocketFactory(TestSocket socket)
{
_socket = socket;
}
public IWebsocket CreateWebsocket(ILogger logger, WebSocketParameters parameters) => _socket;
}
}
@@ -0,0 +1,100 @@
using CryptoExchange.Net.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Diagnostics;
using System.Linq.Expressions;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Testing
{
/// <summary>
/// Base class for executing REST API integration tests
/// </summary>
/// <typeparam name="TClient">Client type</typeparam>
public abstract class RestIntergrationTest<TClient>
{
/// <summary>
/// Get a client instance
/// </summary>
/// <param name="loggerFactory"></param>
/// <returns></returns>
public abstract TClient GetClient(ILoggerFactory loggerFactory);
/// <summary>
/// Whether the test should be run. By default integration tests aren't executed, can be set to true to force execution.
/// </summary>
public virtual bool Run { get; set; }
/// <summary>
/// Whether API credentials are provided and thus authenticated calls can be executed. Should be set in the GetClient implementation.
/// </summary>
public bool Authenticated { get; set; }
/// <summary>
/// Create a client
/// </summary>
/// <returns></returns>
protected TClient CreateClient()
{
var fact = new LoggerFactory();
fact.AddProvider(new TraceLoggerProvider());
return GetClient(fact);
}
/// <summary>
/// Check if integration tests should be executed
/// </summary>
/// <returns></returns>
protected bool ShouldRun()
{
var integrationTests = Environment.GetEnvironmentVariable("INTEGRATION");
if (!Run && integrationTests != "1")
return false;
return true;
}
/// <summary>
/// Execute a REST endpoint call and check for any errors or warnings.
/// </summary>
/// <typeparam name="T">Type of response</typeparam>
/// <param name="expression">The call expression</param>
/// <param name="authRequest">Whether this is an authenticated request</param>
public async Task RunAndCheckResult<T>(Expression<Func<TClient, Task<WebCallResult<T>>>> expression, bool authRequest)
{
if (!ShouldRun())
return;
var client = CreateClient();
var expressionBody = (MethodCallExpression)expression.Body;
if (authRequest && !Authenticated)
{
Debug.WriteLine($"Skipping {expressionBody.Method.Name}, not authenticated");
return;
}
var listener = new EnumValueTraceListener();
Trace.Listeners.Add(listener);
WebCallResult<T> result;
try
{
result = await expression.Compile().Invoke(client).ConfigureAwait(false);
}
catch (Exception ex)
{
throw new Exception($"Method {expressionBody.Method.Name} threw an exception: " + ex.ToLogString());
}
finally
{
Trace.Listeners.Remove(listener);
}
if (!result.Success)
throw new Exception($"Method {expressionBody.Method.Name} returned error: " + result.Error);
Debug.WriteLine($"{expressionBody.Method.Name} {result}");
}
}
}
@@ -0,0 +1,191 @@
using CryptoExchange.Net.Clients;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Testing.Comparers;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Testing
{
/// <summary>
/// Validator for REST requests, comparing path, http method, authentication and response parsing
/// </summary>
/// <typeparam name="TClient">The Rest client</typeparam>
public class RestRequestValidator<TClient> where TClient : BaseRestClient
{
private readonly TClient _client;
private readonly Func<WebCallResult, bool> _isAuthenticated;
private readonly string _folder;
private readonly string _baseAddress;
private readonly string? _nestedPropertyForCompare;
private readonly bool _stjCompare;
/// <summary>
/// ctor
/// </summary>
/// <param name="client">Client to test</param>
/// <param name="folder">Folder for json test values</param>
/// <param name="baseAddress">The base address that is expected</param>
/// <param name="isAuthenticated">Func for checking if the request is authenticated</param>
/// <param name="nestedPropertyForCompare">Property to use for compare</param>
/// <param name="stjCompare">Use System.Text.Json for comparing</param>
public RestRequestValidator(TClient client, string folder, string baseAddress, Func<WebCallResult, bool> isAuthenticated, string? nestedPropertyForCompare = null, bool stjCompare = true)
{
_client = client;
_folder = folder;
_baseAddress = baseAddress;
_nestedPropertyForCompare = nestedPropertyForCompare;
_isAuthenticated = isAuthenticated;
_stjCompare = stjCompare;
}
/// <summary>
/// Validate a request
/// </summary>
/// <typeparam name="TResponse">Expected response type</typeparam>
/// <param name="methodInvoke">Method invocation</param>
/// <param name="name">Method name for looking up json test values</param>
/// <param name="nestedJsonProperty">Use nested json property for compare</param>
/// <param name="ignoreProperties">Ignore certain properties</param>
/// <param name="useSingleArrayItem">Use the first item of an json array response</param>
/// <param name="skipResponseValidation">Whether to skip the response model validation</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public Task ValidateAsync<TResponse>(
Func<TClient, Task<WebCallResult<TResponse>>> methodInvoke,
string name,
string? nestedJsonProperty = null,
List<string>? ignoreProperties = null,
bool useSingleArrayItem = false,
bool skipResponseValidation = false)
=> ValidateAsync<TResponse, TResponse>(methodInvoke, name, nestedJsonProperty, ignoreProperties, useSingleArrayItem, skipResponseValidation);
/// <summary>
/// Validate a request
/// </summary>
/// <typeparam name="TResponse">Expected response type</typeparam>
/// <typeparam name="TActualResponse">The concrete response type</typeparam>
/// <param name="methodInvoke">Method invocation</param>
/// <param name="name">Method name for looking up json test values</param>
/// <param name="nestedJsonProperty">Use nested json property for compare</param>
/// <param name="ignoreProperties">Ignore certain properties</param>
/// <param name="useSingleArrayItem">Use the first item of an json array response</param>
/// <param name="skipResponseValidation">Whether to skip the response model validation</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task ValidateAsync<TResponse, TActualResponse>(
Func<TClient, Task<WebCallResult<TResponse>>> methodInvoke,
string name,
string? nestedJsonProperty = null,
List<string>? ignoreProperties = null,
bool useSingleArrayItem = false,
bool skipResponseValidation = false) where TActualResponse : TResponse
{
var listener = new EnumValueTraceListener();
Trace.Listeners.Add(listener);
var path = Directory.GetParent(Environment.CurrentDirectory)!.Parent!.Parent!.FullName;
FileStream file;
try
{
file = File.OpenRead(Path.Combine(path, _folder, $"{name}.txt"));
}
catch (FileNotFoundException)
{
throw new Exception($"Response file not found for {name}: {path}");
}
var buffer = new byte[file.Length];
await file.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
file.Close();
var data = Encoding.UTF8.GetString(buffer);
using var reader = new StringReader(data);
var expectedMethod = reader.ReadLine();
var expectedPath = reader.ReadLine();
var expectedAuth = bool.Parse(reader.ReadLine()!);
var response = reader.ReadToEnd();
TestHelpers.ConfigureRestClient(_client, response, System.Net.HttpStatusCode.OK);
var result = await methodInvoke(_client).ConfigureAwait(false);
// Check request/response properties
if (result.Error != null)
throw new Exception(name + " returned error " + result.Error);
if (_isAuthenticated(result.AsDataless()) != expectedAuth)
throw new Exception(name + $" authentication not matched. Expected: {expectedAuth}, Actual: {_isAuthenticated(result.AsDataless())}");
if (result.RequestMethod != new HttpMethod(expectedMethod!))
throw new Exception(name + $" http method not matched. Expected {expectedMethod}, Actual: {result.RequestMethod}");
if (expectedPath != result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0])
throw new Exception(name + $" path not matched. Expected: {expectedPath}, Actual: {result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0]}");
if (!skipResponseValidation)
{
// Check response data
object responseData = (TActualResponse)result.Data!;
if (_stjCompare == true)
SystemTextJsonComparer.CompareData(name, responseData, response, nestedJsonProperty ?? _nestedPropertyForCompare, ignoreProperties, useSingleArrayItem);
else
JsonNetComparer.CompareData(name, responseData, response, nestedJsonProperty ?? _nestedPropertyForCompare, ignoreProperties, useSingleArrayItem);
}
Trace.Listeners.Remove(listener);
}
/// <summary>
/// Validate a request
/// </summary>
/// <param name="methodInvoke">Method invocation</param>
/// <param name="name">Method name for looking up json test values</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task ValidateAsync(
Func<TClient, Task<WebCallResult>> methodInvoke,
string name)
{
var listener = new EnumValueTraceListener();
Trace.Listeners.Add(listener);
var path = Directory.GetParent(Environment.CurrentDirectory)!.Parent!.Parent!.FullName;
FileStream file;
try
{
file = File.OpenRead(Path.Combine(path, _folder, $"{name}.txt"));
}
catch (FileNotFoundException)
{
throw new Exception($"Response file not found for {name}: {path}");
}
var buffer = new byte[file.Length];
await file.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
file.Close();
var data = Encoding.UTF8.GetString(buffer);
using var reader = new StringReader(data);
var expectedMethod = reader.ReadLine();
var expectedPath = reader.ReadLine();
var expectedAuth = bool.Parse(reader.ReadLine()!);
var response = reader.ReadToEnd();
TestHelpers.ConfigureRestClient(_client, response, System.Net.HttpStatusCode.OK);
var result = await methodInvoke(_client).ConfigureAwait(false);
// Check request/response properties
if (result.Error != null)
throw new Exception(name + " returned error " + result.Error);
if (_isAuthenticated(result) != expectedAuth)
throw new Exception(name + $" authentication not matched. Expected: {expectedAuth}, Actual: {_isAuthenticated(result)}");
if (result.RequestMethod != new HttpMethod(expectedMethod!))
throw new Exception(name + $" http method not matched. Expected {expectedMethod}, Actual: {result.RequestMethod}");
if (expectedPath != result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0])
throw new Exception(name + $" path not matched. Expected: {expectedPath}, Actual: {result.RequestUrl!.Replace(_baseAddress, "").Split(new char[] { '?' })[0]}");
Trace.Listeners.Remove(listener);
}
}
}
@@ -0,0 +1,174 @@
using CryptoExchange.Net.Clients;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.Testing.Comparers;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Testing
{
/// <summary>
/// Validator for websocket subscriptions, checking expected requests and responses and comparing update models
/// </summary>
/// <typeparam name="TClient"></typeparam>
public class SocketSubscriptionValidator<TClient> where TClient : BaseSocketClient
{
private readonly TClient _client;
private readonly string _folder;
private readonly string _baseAddress;
private readonly string? _nestedPropertyForCompare;
private readonly bool _stjCompare;
/// <summary>
/// ctor
/// </summary>
/// <param name="client">Client to test</param>
/// <param name="folder">Folder for json test values</param>
/// <param name="baseAddress">The base address that is expected</param>
/// <param name="nestedPropertyForCompare">Property to use for compare</param>
/// <param name="stjCompare">Use System.Text.Json for comparing</param>
public SocketSubscriptionValidator(TClient client, string folder, string baseAddress, string? nestedPropertyForCompare = null, bool stjCompare = true)
{
_client = client;
_folder = folder;
_baseAddress = baseAddress;
_nestedPropertyForCompare = nestedPropertyForCompare;
_stjCompare = stjCompare;
}
/// <summary>
/// Validate a subscription
/// </summary>
/// <typeparam name="TUpdate">The expected update type</typeparam>
/// <param name="methodInvoke">Subscription method invocation</param>
/// <param name="name">Method name for looking up json test values</param>
/// <param name="nestedJsonProperty">Use nested json property for compare</param>
/// <param name="ignoreProperties">Ignore certain properties</param>
/// <param name="useFirstUpdateItem">Use the first item of an array update</param>
/// <param name="addressPath">Path</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public async Task ValidateAsync<TUpdate>(
Func<TClient, Action<DataEvent<TUpdate>>, Task<CallResult<UpdateSubscription>>> methodInvoke,
string name,
string? nestedJsonProperty = null,
List<string>? ignoreProperties = null,
string? addressPath = null,
bool? useFirstUpdateItem = null)
{
var listener = new EnumValueTraceListener();
Trace.Listeners.Add(listener);
var path = Directory.GetParent(Environment.CurrentDirectory)!.Parent!.Parent!.FullName;
FileStream file ;
try
{
file = File.OpenRead(Path.Combine(path, _folder, $"{name}.txt"));
}
catch (FileNotFoundException)
{
throw new Exception("Response file not found");
}
var buffer = new byte[file.Length];
await file.ReadAsync(buffer, 0, (int)file.Length).ConfigureAwait(false);
file.Close();
var data = Encoding.UTF8.GetString(buffer);
using var reader = new StringReader(data);
var socket = TestHelpers.ConfigureSocketClient(_client, addressPath == null ? _baseAddress : _baseAddress.AppendPath(addressPath));
var waiter = new AutoResetEvent(false);
string? lastMessage = null;
socket.OnMessageSend += (x) =>
{
lastMessage = x;
waiter.Set();
};
TUpdate? update = default;
// Invoke subscription method
var task = methodInvoke(_client, x => { update = x.Data; });
string? overrideKey = null;
string? overrideValue = null;
while (true)
{
var line = reader.ReadLine();
if (line == null)
break;
if (line.StartsWith("> "))
{
// Expect a message from client to server
waiter.WaitOne(TimeSpan.FromSeconds(1));
if (lastMessage == null)
throw new Exception($"{name} expected to {line} to be send to server but did not receive anything");
var lastMessageJson = JToken.Parse(lastMessage);
var expectedJson = JToken.Parse(line.Substring(2));
foreach(var item in expectedJson)
{
if (item is JProperty prop && prop.Value is JValue val)
{
if (val.ToString().StartsWith("|") && val.ToString().EndsWith("|"))
{
// |x| values are used to replace parts or response messages
overrideKey = val.ToString();
overrideValue = lastMessageJson[prop.Name]?.Value<string>();
}
else if (val.ToString() == "-999")
{
// -999 value is used to replace parts or response messages
overrideKey = val.ToString();
overrideValue = lastMessageJson[prop.Name]?.Value<decimal>().ToString();
}
else if (lastMessageJson[prop.Name]?.Value<string>() != val.ToString() && ignoreProperties?.Contains(prop.Name) != true)
throw new Exception($"{name} Expected {prop.Name} to be {val}, but was {lastMessageJson[prop.Name]?.Value<string>()}");
}
// TODO check objects and arrays
}
}
else if (line.StartsWith("< "))
{
// Expect a message from server to client
if (overrideKey != null)
{
line = line.Replace(overrideKey, overrideValue);
overrideKey = null;
overrideValue = null;
}
socket.InvokeMessage(line.Substring(2));
}
else
{
// A update message from server to client
var compareData = reader.ReadToEnd();
socket.InvokeMessage(compareData);
if (update == null)
throw new Exception($"{name} Update send to client did not trigger in update handler");
if (_stjCompare == true)
SystemTextJsonComparer.CompareData(name, update, compareData, nestedJsonProperty ?? _nestedPropertyForCompare, ignoreProperties, useFirstUpdateItem ?? false);
else
JsonNetComparer.CompareData(name, update, compareData, nestedJsonProperty ?? _nestedPropertyForCompare, ignoreProperties, useFirstUpdateItem ?? false);
}
}
await _client.UnsubscribeAllAsync().ConfigureAwait(false);
Trace.Listeners.Remove(listener);
}
}
}
+191
View File
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Clients;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.Testing.Implementations;
namespace CryptoExchange.Net.Testing
{
/// <summary>
/// Testing helpers
/// </summary>
public class TestHelpers
{
[ExcludeFromCodeCoverage]
internal static bool AreEqual<T>(T? self, T? to, params string[] ignore) where T : class
{
if (self != null && to != null)
{
var type = self.GetType();
var ignoreList = new List<string>(ignore);
foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (ignoreList.Contains(pi.Name))
continue;
var selfValue = type.GetProperty(pi.Name)!.GetValue(self, null);
var toValue = type.GetProperty(pi.Name)!.GetValue(to, null);
if (pi.PropertyType.IsClass && !pi.PropertyType.Module.ScopeName.Equals("System.Private.CoreLib.dll"))
{
// Check of "CommonLanguageRuntimeLibrary" is needed because string is also a class
if (AreEqual(selfValue, toValue, ignore))
continue;
return false;
}
if (selfValue != toValue && (selfValue == null || !selfValue.Equals(toValue)))
return false;
}
return true;
}
return self == to;
}
internal static TestSocket ConfigureSocketClient<T>(T client, string address) where T : BaseSocketClient
{
var socket = new TestSocket(address);
foreach (var apiClient in client.ApiClients.OfType<SocketApiClient>())
{
apiClient.SocketFactory = new TestWebsocketFactory(socket);
}
return socket;
}
internal static void ConfigureRestClient<T>(T client, string data, HttpStatusCode code) where T : BaseRestClient
{
foreach (var apiClient in client.ApiClients.OfType<RestApiClient>())
{
var expectedBytes = Encoding.UTF8.GetBytes(data);
var responseStream = new MemoryStream();
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
responseStream.Seek(0, SeekOrigin.Begin);
var response = new TestResponse(code, responseStream);
var request = new TestRequest(response);
var factory = new TestRequestFactory(request);
apiClient.RequestFactory = factory;
}
}
/// <summary>
/// Check a signature matches the expected signature
/// </summary>
/// <param name="client"></param>
/// <param name="authProvider"></param>
/// <param name="method"></param>
/// <param name="path"></param>
/// <param name="getSignature"></param>
/// <param name="expectedSignature"></param>
/// <param name="parameters"></param>
/// <param name="time"></param>
/// <param name="disableOrdering"></param>
/// <param name="compareCase"></param>
/// <param name="host"></param>
/// <exception cref="Exception"></exception>
public static void CheckSignature(
RestApiClient client,
AuthenticationProvider authProvider,
HttpMethod method,
string path,
Func<IDictionary<string, object>?, IDictionary<string, object>?, IDictionary<string, string>?, string> getSignature,
string expectedSignature,
Dictionary<string, object>? parameters = null,
DateTime? time = null,
bool disableOrdering = false,
bool compareCase = true,
string host = "https://test.test-api.com")
{
parameters ??= new Dictionary<string, object>
{
{ "test", 123 },
{ "test2", "abc" }
};
if (disableOrdering)
client.OrderParameters = false;
var uriParams = client.ParameterPositions[method] == HttpMethodParameterPosition.InUri ? client.CreateParameterDictionary(parameters) : new Dictionary<string, object>();
var bodyParams = client.ParameterPositions[method] == HttpMethodParameterPosition.InBody ? client.CreateParameterDictionary(parameters) : new Dictionary<string, object>();
var headers = new Dictionary<string, string>();
authProvider.TimeProvider = new TestAuthTimeProvider(time ?? new DateTime(2024, 01, 01, 0, 0, 0, DateTimeKind.Utc));
authProvider.AuthenticateRequest(
client,
new Uri(host.AppendPath(path)),
method,
uriParams,
bodyParams,
headers,
true,
client.ArraySerialization,
client.ParameterPositions[method],
client.RequestBodyFormat
);
var signature = getSignature(uriParams, bodyParams, headers);
if (!string.Equals(signature, expectedSignature, compareCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase))
throw new Exception($"Signatures do not match. Expected: {expectedSignature}, Actual: {signature}");
}
/// <summary>
/// Scan the TClient rest client type for missing interface methods
/// </summary>
/// <typeparam name="TClient"></typeparam>
/// <exception cref="Exception"></exception>
public static void CheckForMissingRestInterfaces<TClient>()
{
CheckForMissingInterfaces(typeof(TClient), typeof(Task));
}
/// <summary>
/// Scan the TClient socket client type for missing interface methods
/// </summary>
/// <typeparam name="TClient"></typeparam>
/// <exception cref="Exception"></exception>
public static void CheckForMissingSocketInterfaces<TClient>()
{
CheckForMissingInterfaces(typeof(TClient), typeof(Task<CallResult<UpdateSubscription>>));
}
private static void CheckForMissingInterfaces(Type clientType, Type implementationTypes)
{
var assembly = Assembly.GetAssembly(clientType);
var interfaceType = clientType.GetInterface("I" + clientType.Name);
var clientInterfaces = assembly.GetTypes().Where(t => t.Name.StartsWith("I" + clientType.Name));
foreach (var clientInterface in clientInterfaces)
{
var implementation = assembly.GetTypes().Single(t => clientInterface.IsAssignableFrom(t) && t != clientInterface);
int methods = 0;
foreach (var method in implementation.GetMethods().Where(m => implementationTypes.IsAssignableFrom(m.ReturnType)))
{
var interfaceMethod = clientInterface.GetMethod(method.Name, method.GetParameters().Select(p => p.ParameterType).ToArray());
if (interfaceMethod == null)
throw new Exception($"Missing interface for method {method.Name} in {implementation.Name} implementing interface {clientInterface.Name}");
methods++;
}
Debug.WriteLine($"{clientInterface.Name} {methods} methods validated");
}
}
}
}
+89 -1
View File
@@ -1,8 +1,9 @@
# CryptoExchange.Net
# ![.CryptoExchange.Net](https://github.com/JKorf/CryptoExchange.Net/blob/ffcb7db8ff597c2f14982d68464015a748815580/CryptoExchange.Net/Icon/icon.png) CryptoExchange.Net
[![.NET](https://img.shields.io/github/actions/workflow/status/JKorf/CryptoExchange.Net/dotnet.yml?style=for-the-badge)](https://github.com/JKorf/CryptoExchange.Net/actions/workflows/dotnet.yml) [![Nuget downloads](https://img.shields.io/nuget/dt/CryptoExchange.Net.svg?style=for-the-badge)](https://www.nuget.org/packages/CryptoExchange.Net) ![License](https://img.shields.io/github/license/JKorf/CryptoExchange.Net?style=for-the-badge)
CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations.
Note that the CryptoExchange.Net package itself can not be used directly for accessing API's. Either install a client library from the list below or use [CryptoClients.Net](https://github.com/jkorf/CryptoClients.Net) which includes access to all exchange API's.
For more information on what CryptoExchange.Net and it's client libraries offers see the [Documentation](https://jkorf.github.io/CryptoExchange.Net/).
@@ -18,12 +19,15 @@ The following API's are directly supported. Note that there are 3rd party implem
|Bybit|[JKorf/Bybit.Net](https://github.com/JKorf/Bybit.Net)|[![Nuget version](https://img.shields.io/nuget/v/Bybit.net.svg?style=flat-square)](https://www.nuget.org/packages/Bybit.Net)|
|CoinEx|[JKorf/CoinEx.Net](https://github.com/JKorf/CoinEx.Net)|[![Nuget version](https://img.shields.io/nuget/v/CoinEx.net.svg?style=flat-square)](https://www.nuget.org/packages/CoinEx.Net)|
|CoinGecko|[JKorf/CoinGecko.Net](https://github.com/JKorf/CoinGecko.Net)|[![Nuget version](https://img.shields.io/nuget/v/CoinGecko.net.svg?style=flat-square)](https://www.nuget.org/packages/CoinGecko.Net)|
|Gate.io|[JKorf/GateIo.Net](https://github.com/JKorf/GateIo.Net)|[![Nuget version](https://img.shields.io/nuget/v/GateIo.net.svg?style=flat-square)](https://www.nuget.org/packages/GateIo.Net)|
|Huobi/HTX|[JKorf/Huobi.Net](https://github.com/JKorf/Huobi.Net)|[![Nuget version](https://img.shields.io/nuget/v/Huobi.net.svg?style=flat-square)](https://www.nuget.org/packages/Huobi.Net)|
|Kraken|[JKorf/Kraken.Net](https://github.com/JKorf/Kraken.Net)|[![Nuget version](https://img.shields.io/nuget/v/KrakenExchange.net.svg?style=flat-square)](https://www.nuget.org/packages/KrakenExchange.Net)|
|Kucoin|[JKorf/Kucoin.Net](https://github.com/JKorf/Kucoin.Net)|[![Nuget version](https://img.shields.io/nuget/v/Kucoin.net.svg?style=flat-square)](https://www.nuget.org/packages/Kucoin.Net)|
|Mexc|[JKorf/Mexc.Net](https://github.com/JKorf/Mexc.Net)|[![Nuget version](https://img.shields.io/nuget/v/JK.Mexc.net.svg?style=flat-square)](https://www.nuget.org/packages/JK.Mexc.Net)|
|OKX|[JKorf/OKX.Net](https://github.com/JKorf/OKX.Net)|[![Nuget version](https://img.shields.io/nuget/v/JK.OKX.net.svg?style=flat-square)](https://www.nuget.org/packages/JK.OKX.Net)|
Any of these can be installed independently or install [CryptoClients.Net](https://github.com/jkorf/CryptoClients.Net) which includes all exchange API's.
## Discord
[![Nuget version](https://img.shields.io/discord/847020490588422145?style=for-the-badge)](https://discord.gg/MSpeEtSY8t)
A Discord server is available [here](https://discord.gg/MSpeEtSY8t). Feel free to join for discussion and/or questions around the CryptoExchange.Net and implementation libraries.
@@ -42,6 +46,90 @@ 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 7.10.0 - 26 Jul 2024
* Added System.Text.Json NumberStringConverter
* Added integration testing base class
* Added AddSecondsString and AddOptionalSecondsString to ParameterCollection
* Added Decompress method for ReadOnlyMemory using non-GZip deflate
* Added SocketConnection parameter to SocketConnection PreprocessStreamMessage
* Fixed websocket reconnect/unsubscribe timing bug
* Fixed issue in System.Text.Json array object deserialization skipping property when skipping an index
* Fixed order book logging bug
* Fixed bug in ParameterCollection AddEnumAsInt
* Version 7.9.0 - 16 Jul 2024
* Added some checks in websocket connection handling
* Added As<T> and AsError<T> methods on untyped WebCallResult
* Updated System.Text.Json package to version 8.0.4 to fix vulnerability
* Updated websocket subscription response handling to remove the thread blocking ManualResetEvent usage
* Updated static logging classes access modifier from internal to public so they can be called in overriden methods
* Updated some testing object implementations
* Fixed authentication error when reconnecting an unauthenticated connection which was marked as dedicated query connection
* Small improvements in SystemTextJsonMessageAccessor
* Fixed System.Text.Json ArrayConverter implementation nullable value types handling
* Version 7.8.0 - 02 Jul 2024
* Updated single endpoint limit configuration
* Added LongConverter for nullable longs
* Updated SystemTextJsonComparer logic
* Version 7.7.3 - 26 Jun 2024
* Fixed request ids not matching in logging
* Added nullable int converter for System.Text.Json
* Small fixes in tests
* Version 7.7.2 - 25 Jun 2024
* Fixed ratelimiting issue possibly creating negative delays
* Version 7.7.1 - 23 Jun 2024
* Fixes for caching implementation
* Version 7.7.0 - 23 Jun 2024
* Caching support
* Caching is supported for GET requests within a certain time frame
* Enable caching by setting CachingEnabled to true in the client options
* Added DataSource to CallResult object
* Dedicated websocket connection
* Added functionality for always having a connection open which can then be used for order operations
* This eliminates the initial connection time for the first request
* WebSocket connection can be prepared by calling PrepareConnectionsAsync on the Api client, for example `await binanceSocketClient.SpotApi.PrepareConnectionsAsync()`. This is only needed initially; it will be reconnected when connection is lost.
* Added CancellationToken support for websocket queries
* Added SocketConnection parameter to SocketApiClient.GetAuthenticationRequest method
* Added ObjectStringConverter base converter for deserializing nested json strings
* Fixed websocket issue with ratelimiting and reconnecting interaction
* Fixed rate limiting issue with sub-millisecond delays
* Fixed websocket connection will now close if authentication fails because of not set credentials
* Updated websocket reconnection handling and options, added backoff policy
* Removed check for confirmed subscription as data often is pushed before the subscription is confirmed
* Version 7.6.0 - 11 Jun 2024
* Added support for specifying seperate uri and body parameters
* Added support for different message and handling generic types on socket queries
* Added support for PATCH http method requests
* Added support for setting http request body to a specific type directly
* Split DataEvent.Topic into StreamId and Symbol properties
* Added support for negative time values parsing
* Added some helper methods for converting DataEvent to CallResult
* Added support for GZip/Deflate automatic decompressing in the default HttpClient
* Updated some testing methods
* Version 7.5.2 - 07 May 2024
* Fixed SetApiCredentials not correctly being used by rate limiter causing exception
* Version 7.5.1 - 03 May 2024
* Some small improvements in unit testing components
* Version 7.5.0 - 01 May 2024
* Added testing implementations
* Small refactor AuthenticationProvider to allow better testing
* Change result of MessageAccessor.Read methods to CallResult so error can be returned
* Moved some DateTimeConverter logic to seperate methods to allow access from outside converters
* Version 7.4.0 - 28 Apr 2024
* Added FormatSymbol on IBaseApiClient interface
* Added IOrderBookFactory interface
* Removed ExchangeOptions as base class for OrderBookOptions
* Version 7.3.3 - 23 Apr 2024
* Added support for new DateTime format parsing
* Updated some logging
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+273 -80
View File
@@ -82,16 +82,17 @@
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="#idocs_installation">Installation</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_di">Dependency Injection</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_general">General Client Usage</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_rest">REST API Client</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_socket">Websocket API Client</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_common">Common clients</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_common">Common Clients</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link" href="#idocs_options">Options & Authorization</a>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="#idocs_auth">Authorization</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_options_set">Setting options</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_options_def">Option definitions</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_options_set">Setting Options</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_options_def">Option Definitions</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link" href="#idocs_features">Additional Features</a>
@@ -99,6 +100,7 @@
<li class="nav-item"><a class="nav-link" href="#idocs_orderbooks">Orderbooks</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_logging">Logging</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_ratelimiting">Ratelimiting</a></li>
<li class="nav-item"><a class="nav-link" href="#idocs_caching">Caching</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link" href="#idocs_examples">Examples</a>
@@ -122,35 +124,39 @@
<div class="idocs-content">
<div class="container">
<section id="idocs_intro">
<h1>CryptoExchange.Net</h1>
<h1>CryptoExchange.Net & Implementations</h1>
<p>CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations.</p>
<div class="alert alert-info">All libraries can be used in the same project as well as indivually, just install the exchange libraries you need!</div>
<p>The following API's are directly supported. Note that there are 3rd party implementations going around, but only these are created and supported by me</p>
<p>When access to multiple or all exchange API's is needed, the CryptoClients.Net library combines all different client libraries in a single Nuget package. The following image illustrates the structure:</p>
<p><img src="assets/images/struct.png" /></p>
<p>These Exchanges/API's are directly supported:</p>
<table class="table table-bordered">
<tr>
<th>Exchange</th>
<th>API</th>
<th>Repository</th>
<th>Nuget</th>
</tr>
<tr><td>Binance</td><td><a href="https://github.com/JKorf/Binance.Net">JKorf/Binance.Net</a></td><td><a href="https://www.nuget.org/packages/Binance.Net"><img src="https://img.shields.io/nuget/v/Binance.net.svg?style=flat-square" /></a></td></tr>
<tr><td>BingX</td><td><a href="https://github.com/JKorf/BingX.Net">JKorf/BingX.Net</a></td><td><a href="https://www.nuget.org/packages/JK.BingX.Net"><img src="https://img.shields.io/nuget/v/JK.BingX.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Bitfinex</td><td><a href="https://github.com/JKorf/Bitfinex.Net">JKorf/Bitfinex.Net</a></td><td><a href="https://www.nuget.org/packages/Bitfinex.Net"><img src="https://img.shields.io/nuget/v/Bitfinex.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Bitget</td><td><a href="https://github.com/JKorf/Bitget.Net">JKorf/Bitget.Net</a></td><td><a href="https://www.nuget.org/packages/JK.Bitget.Net"><img src="https://img.shields.io/nuget/v/JK.Bitget.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Bybit</td><td><a href="https://github.com/JKorf/Bybit.Net">JKorf/Bybit.Net</a></td><td><a href="https://www.nuget.org/packages/Bybit.Net"><img src="https://img.shields.io/nuget/v/Bybit.net.svg?style=flat-square" /></a></td></tr>
<tr><td>CoinEx</td><td><a href="https://github.com/JKorf/CoinEx.Net">JKorf/CoinEx.Net</a></td><td><a href="https://www.nuget.org/packages/CoinEx.Net"><img src="https://img.shields.io/nuget/v/CoinEx.net.svg?style=flat-square" /></a></td></tr>
<tr><td>CoinGecko</td><td><a href="https://github.com/JKorf/CoinGecko.Net">JKorf/CoinGecko.Net</a></td><td><a href="https://www.nuget.org/packages/CoinGecko.Net"><img src="https://img.shields.io/nuget/v/CoinGecko.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Huobi</td><td><a href="https://github.com/JKorf/Huobi.Net">JKorf/Huobi.Net</a></td><td><a href="https://www.nuget.org/packages/Huobi.Net"><img src="https://img.shields.io/nuget/v/Huobi.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Kraken</td><td><a href="https://github.com/JKorf/Kraken.Net">JKorf/Kraken.Net</a></td><td><a href="https://www.nuget.org/packages/KrakenExchange.Net"><img src="https://img.shields.io/nuget/v/KrakenExchange.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Kucoin</td><td><a href="https://github.com/JKorf/Kucoin.Net">JKorf/Kucoin.Net</a></td><td><a href="https://www.nuget.org/packages/Kucoin.Net"><img src="https://img.shields.io/nuget/v/Kucoin.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Mexc</td><td><a href="https://github.com/JKorf/Mexc.Net">JKorf/Mexc.Net</a></td><td><a href="https://www.nuget.org/packages/JK.Mexc.Net"><img src="https://img.shields.io/nuget/v/JK.Mexc.net.svg?style=flat-square" /></a></td></tr>
<tr><td>OKX</td><td><a href="https://github.com/JKorf/OKX.Net">JKorf/OKX.Net</a></td><td><a href="https://www.nuget.org/packages/JK.OKX.Net"><img src="https://img.shields.io/nuget/v/JK.OKX.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Binance</td><td><a href="https://github.com/JKorf/Binance.Net">JKorf/Binance.Net</a></td><td><a href="https://www.nuget.org/packages/Binance.Net" target="_blank"><img src="https://img.shields.io/nuget/v/Binance.net.svg?style=flat-square" /></a></td></tr>
<tr><td>BingX</td><td><a href="https://github.com/JKorf/BingX.Net">JKorf/BingX.Net</a></td><td><a href="https://www.nuget.org/packages/JK.BingX.Net" target="_blank"><img src="https://img.shields.io/nuget/v/JK.BingX.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Bitfinex</td><td><a href="https://github.com/JKorf/Bitfinex.Net">JKorf/Bitfinex.Net</a></td><td><a href="https://www.nuget.org/packages/Bitfinex.Net" target="_blank"><img src="https://img.shields.io/nuget/v/Bitfinex.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Bitget</td><td><a href="https://github.com/JKorf/Bitget.Net">JKorf/Bitget.Net</a></td><td><a href="https://www.nuget.org/packages/JK.Bitget.Net" target="_blank"><img src="https://img.shields.io/nuget/v/JK.Bitget.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Bybit</td><td><a href="https://github.com/JKorf/Bybit.Net">JKorf/Bybit.Net</a></td><td><a href="https://www.nuget.org/packages/Bybit.Net" target="_blank"><img src="https://img.shields.io/nuget/v/Bybit.net.svg?style=flat-square" /></a></td></tr>
<tr><td>CoinEx</td><td><a href="https://github.com/JKorf/CoinEx.Net">JKorf/CoinEx.Net</a></td><td><a href="https://www.nuget.org/packages/CoinEx.Net" target="_blank"><img src="https://img.shields.io/nuget/v/CoinEx.net.svg?style=flat-square" /></a></td></tr>
<tr><td>CoinGecko</td><td><a href="https://github.com/JKorf/CoinGecko.Net">JKorf/CoinGecko.Net</a></td><td><a href="https://www.nuget.org/packages/CoinGecko.Net" target="_blank"><img src="https://img.shields.io/nuget/v/CoinGecko.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Gate.io</td><td><a href="https://github.com/JKorf/GateIo.Net">JKorf/GateIo.Net</a></td><td><a href="https://www.nuget.org/packages/GateIo.Net" target="_blank"><img src="https://img.shields.io/nuget/v/GateIo.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Huobi</td><td><a href="https://github.com/JKorf/Huobi.Net">JKorf/Huobi.Net</a></td><td><a href="https://www.nuget.org/packages/Huobi.Net" target="_blank"><img src="https://img.shields.io/nuget/v/Huobi.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Kraken</td><td><a href="https://github.com/JKorf/Kraken.Net">JKorf/Kraken.Net</a></td><td><a href="https://www.nuget.org/packages/KrakenExchange.Net" target="_blank"><img src="https://img.shields.io/nuget/v/KrakenExchange.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Kucoin</td><td><a href="https://github.com/JKorf/Kucoin.Net">JKorf/Kucoin.Net</a></td><td><a href="https://www.nuget.org/packages/Kucoin.Net" target="_blank"><img src="https://img.shields.io/nuget/v/Kucoin.net.svg?style=flat-square" /></a></td></tr>
<tr><td>Mexc</td><td><a href="https://github.com/JKorf/Mexc.Net">JKorf/Mexc.Net</a></td><td><a href="https://www.nuget.org/packages/JK.Mexc.Net" target="_blank"><img src="https://img.shields.io/nuget/v/JK.Mexc.net.svg?style=flat-square" /></a></td></tr>
<tr><td>OKX</td><td><a href="https://github.com/JKorf/OKX.Net">JKorf/OKX.Net</a></td><td><a href="https://www.nuget.org/packages/JK.OKX.Net" target="_blank"><img src="https://img.shields.io/nuget/v/JK.OKX.net.svg?style=flat-square" /></a></td></tr>
</table>
<p>Note that there are 3rd party implementations going around, but only the listed ones here are created and supported by me.</p>
<p>When using multiple of these API's the <a href="https://github.com/jkorf/CryptoClients.Net">CryptoClients.Net</a> package can be used which combines these packages and allows easy access to all exchange API's.</p>
<h4>Supported Frameworks</h4>
<p>
The library is targeting both <code>.NET Standard 2.0</code> and <code>.NET Standard 2.1</code> for optimal compatibility
The libraries are targeting both <code>.NET Standard 2.0</code> and <code>.NET Standard 2.1</code> for optimal compatibility
</p>
<table class="table table-bordered">
<tr>
@@ -202,11 +208,14 @@
<section id="idocs_installation">
<h2>Installation</h2>
<p>Add the package via dotnet, or add it via the package manager. Any number of libraries can be installed, just make sure you're always using the latest at that moment.</p>
<p>Add the package via dotnet, or add it via the package manager. Any number of libraries can be installed, just make sure you're always using the latest at that moment. Instead of installing all libraries seperately the CryptoClients.Net library can be installed to include all the exchange package in one go.</p>
<div class="tab-wrap">
<ul class="nav nav-tabs" id="install" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="install-binance-tab" data-toggle="tab" href="#install-binance" role="tab" aria-controls="install-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="install-cc-tab" data-toggle="tab" href="#install-cc" role="tab" aria-controls="install-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="install-binance-tab" data-toggle="tab" href="#install-binance" role="tab" aria-controls="install-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="install-bingx-tab" data-toggle="tab" href="#install-bingx" role="tab" aria-controls="install-bingx" aria-selected="false">BingX</a>
@@ -243,7 +252,10 @@
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="install-binance" role="tabpanel" aria-labelledby="install-binance-tab">
<div class="tab-pane fade show active" id="install-cc" role="tabpanel" aria-labelledby="install-cc-tab">
<pre><code>dotnet add package CryptoClients.Net</code></pre>
</div>
<div class="tab-pane fade show" id="install-binance" role="tabpanel" aria-labelledby="install-binance-tab">
<pre><code>dotnet add package Binance.Net</code></pre>
<img src="assets/images/BinanceInstall.png" />
</div>
@@ -299,13 +311,16 @@
<section id="idocs_di">
<h2>Dependency Injection</h2>
<p>
All client libraries support and encourage usage via the Dotnet dependency injection system. Add all necesary services by calling the <code>Add[Library]();</code> extension method on the service collection. <a href="#idocs_options_set">Options</a> for the clients can be passed as parameters.
All client libraries support and encourage usage via the Dotnet dependency injection system. Add all necesary services per exchange by calling the <code>Add[Library]();</code> extension method on the service collection, or use <code>AddCryptoClients()</code> to add all exchange services in a single class. <a href="#idocs_options_set">Options</a> for the clients can be passed as parameters.
</p>
<div class="alert alert-info">Using the dependecy injection mechanism also makes sure the HttpClient is used correctly</div>
<div class="tab-wrap">
<ul class="nav nav-tabs" id="di" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="di-cc-tab" data-toggle="tab" href="#di-cc" role="tab" aria-controls="di-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link active" id="di-binance-tab" data-toggle="tab" href="#di-binance" role="tab" aria-controls="di-binance" aria-selected="true">Binance</a>
<a class="nav-link" id="di-binance-tab" data-toggle="tab" href="#di-binance" role="tab" aria-controls="di-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="di-bingx-tab" data-toggle="tab" href="#di-bingx" role="tab" aria-controls="di-bingx" aria-selected="false">BingX</a>
@@ -342,7 +357,10 @@
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="di-binance" role="tabpanel" aria-labelledby="di-binance-tab">
<div class="tab-pane fade show active" id="di-cc" role="tabpanel" aria-labelledby="di-cc-tab">
<pre><code>builder.Services.AddCryptoClients();</code></pre>
</div>
<div class="tab-pane fade" id="di-binance" role="tabpanel" aria-labelledby="di-binance-tab">
<pre><code>builder.Services.AddBinance();</code></pre>
</div>
<div class="tab-pane fade" id="di-bingx" role="tabpanel" aria-labelledby="di-bingx-tab">
@@ -385,7 +403,10 @@
<div class="tab-wrap">
<ul class="nav nav-tabs" id="interfaces" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="interfaces-binance-tab" data-toggle="tab" href="#interfaces-binance" role="tab" aria-controls="interfaces-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="interfaces-cc-tab" data-toggle="tab" href="#interfaces-cc" role="tab" aria-controls="interfaces-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="interfaces-binance-tab" data-toggle="tab" href="#interfaces-binance" role="tab" aria-controls="interfaces-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="interfaces-bingx-tab" data-toggle="tab" href="#interfaces-bingx" role="tab" aria-controls="interfaces-bingx" aria-selected="false">BingX</a>
@@ -422,7 +443,41 @@
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="interfaces-binance" role="tabpanel" aria-labelledby="interfaces-binance-tab">
<div class="tab-pane fade show active" id="interfaces-cc" role="tabpanel" aria-labelledby="interfaces-cc-tab">
<table class="table table-bordered">
<tr><th>Interface</th><th>Description</th></tr>
<tr>
<td><code>IExchangeRestClient</code></td>
<td>The client for accessing all exchanges REST API's</td>
</tr>
<tr>
<td><code>IExchangeSocketClient</code></td>
<td>The client for accessing all exchanges Websocket API's</td>
</tr>
<tr>
<td><code>IExchangeOrderBookFactory</code></td>
<td>A factory class for accessing all exchanges SymbolOrderBook (locally synced order books) factory methods</td>
</tr>
<tr>
<td><code>I[Library]RestClient</code></td>
<td>All exchange specific REST clients, for example <code>IBinanceRestClient</code> and <code>IMexcRestClient</code></td>
</tr>
<tr>
<td><code>I[Library]SocketClient</code></td>
<td>All exchange specific Websocket clients, for example <code>IBinanceSocketClient</code> and <code>IMexcSocketClient</code></td>
</tr>
<tr>
<td><code>I[Library]OrderBookFactory</code></td>
<td>All exchange specific order book factories. The factory can be used for creating SymbolOrderBook (locally synced order books) instances for the exchange</td>
</tr>
<tr>
<td><code>ISpotClient</code></td>
<td>An implementation of the ISpotClient interface for each exchange. The ISpotClient offers basic Spot API functionality in a combined interface</td>
</tr>
</table>
</div>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade" id="interfaces-binance" role="tabpanel" aria-labelledby="interfaces-binance-tab">
<table class="table table-bordered">
<tr><th>Interface</th><th>Description</th></tr>
<tr>
@@ -760,6 +815,15 @@
<hr class="divider">
<section id="idocs_general">
<h2>General client usage</h2>
<p>All clients work with the same principles:</p>
<ul>
<li>Mandatory parameters are non-nullable while optional parameters are nullable and will have a default value of null.</li>
<li>Any operation will return a form of <code>CallResult</code>. This result can and should be check for success. The clients will not throw exceptions.</li>
</ul>
</section>
<!-- HTML Structure
============================ -->
<section id="idocs_rest">
@@ -770,7 +834,10 @@
<div class="tab-wrap">
<ul class="nav nav-tabs" id="rest" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="rest-binance-tab" data-toggle="tab" href="#rest-binance" role="tab" aria-controls="rest-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="rest-cc-tab" data-toggle="tab" href="#rest-cc" role="tab" aria-controls="rest-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="rest-binance-tab" data-toggle="tab" href="#rest-binance" role="tab" aria-controls="rest-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="rest-bingx-tab" data-toggle="tab" href="#rest-bingx" role="tab" aria-controls="rest-bingx" aria-selected="false">BingX</a>
@@ -807,7 +874,19 @@
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="rest-binance" role="tabpanel" aria-labelledby="rest-binance-tab">
<div class="tab-pane fade show active" id="rest-cc" role="tabpanel" aria-labelledby="rest-cc-tab">
<pre><code>var client = new ExchangeRestClient();
var tickersResult = await client.Binance.SpotApi.ExchangeData.GetTickersAsync();
if (!tickersResult.Success)
{
// Handle error, tickersResult.Error contains more information
}
else
{
// Handle data, tickersResult.Data will contain the actual data
}</code></pre>
</div>
<div class="tab-pane fade" id="rest-binance" role="tabpanel" aria-labelledby="rest-binance-tab">
<pre><code>var client = new BinanceRestClient();
var tickersResult = await client.SpotApi.ExchangeData.GetTickersAsync();
if (!tickersResult.Success)
@@ -1020,11 +1099,16 @@ else
<p>The client can be injected via <a href="#di">dependency injection</a>, or constructed manually. When constructing manually keep in mind that when the client is disposed all connections will get closed as well.</p>
<div class="alert alert-info">The socket client requires a continous connection to the server. The connection can be lost due to internet interuptions, or the server disconnecting the client. This is expected behaviour and the socket client will automatically reconnect when this happens.</div>
<h4>Subscribing</h4>
<div class="tab-wrap">
<ul class="nav nav-tabs" id="socket" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="socket-binance-tab" data-toggle="tab" href="#socket-binance" role="tab" aria-controls="socket-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="socket-cc-tab" data-toggle="tab" href="#socket-cc" role="tab" aria-controls="socket-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="socket-binance-tab" data-toggle="tab" href="#socket-binance" role="tab" aria-controls="socket-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="socket-bingx-tab" data-toggle="tab" href="#socket-bingx" role="tab" aria-controls="socket-bingx" aria-selected="false">BingX</a>
@@ -1058,7 +1142,18 @@ else
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="socket-binance" role="tabpanel" aria-labelledby="socket-binance-tab">
<div class="tab-pane fade show active" id="socket-cc" role="tabpanel" aria-labelledby="socket-cc-tab">
<pre><code>var client = new ExchangeSocketClient();
var subscribeResult = await client.Binance.SpotApi.ExchangeData.SubscribeToAllTickerUpdatesAsync(update => {
// Handle the data update, update.Data will contain the actual data
});
if (!subscribeResult.Success)
{
// Handle error, subscribeResult.Error contains more information on why the subscription failed
}
// Subscribing was successfull, the data will now be streamed into the data handler</code></pre>
</div>
<div class="tab-pane fade" id="socket-binance" role="tabpanel" aria-labelledby="socket-binance-tab">
<pre><code>var client = new BinanceSocketClient();
var subscribeResult = await client.SpotApi.ExchangeData.SubscribeToAllTickerUpdatesAsync(update => {
// Handle the data update, update.Data will contain the actual data
@@ -1294,34 +1389,34 @@ await client.UnsubscribeAllAsync();</code></pre>
============================ -->
<section id="idocs_common">
<h2>Common Clients</h2>
<p>CryptoExchange.Net exposes some common clients. These clients aim to make using the different API's easier.</p>
<p>The CryptoClients.Net library exposes two client classes. These clients aim to make using the different API's easier.</p>
<p><b>(I)CryptoRestClient</b><br />
The <code>ICryptoRestClient</code> (or <code>CryptoRestClient</code> when used directly) can be used to easily access REST clients for different API's through the different packages that have been installed. Each package adds it extension method to the interface, which allows the user to access the clients via it.
<p><b>(I)ExchangeRestClient</b><br />
The <code>IExchangeRestClient</code> (or <code>ExchangeRestClient</code> when used directly) can be used to easily access REST clients for different API's.
</p>
<p>
For example, having the Binance, Bybit and Kucoin packages installed allows you to use it like this:
<pre><code>var cryptoRestClient = new CryptoRestClient(); // Either construct it or inject the ICryptoRestClient into your service
var binanceTicker = await cryptoRestClient.Binance().SpotApi.ExchangeData.GetTickersAsync();
var bybitTicker = await cryptoRestClient.Bybit().V5Api.ExchangeData.GetTickers();
var kucoinTicker = await cryptoRestClient.Kucoin().SpotApi.ExchangeData.GetTickers();</code></pre>
For example, using the Binance, Bybit and Kucoin API's can be done like this:
<pre><code>var exchangeRestClient = new ExchangeRestClient(); // Either construct it or inject the IExchangeRestClient into your service after having called 'AddCryptoClients()' during service regirations
var binanceTicker = await exchangeRestClient.Binance.SpotApi.ExchangeData.GetTickersAsync();
var bybitTicker = await exchangeRestClient.Bybit.V5Api.ExchangeData.GetTickers();
var kucoinTicker = await exchangeRestClient.Kucoin.SpotApi.ExchangeData.GetTickers();</code></pre>
</p>
<p><b>(I)CryptoSocketClient</b><br />
Similarly as the (I)CryptoRestClient this client allows you to access the different Websocket clients through a single access point.
<p><b>(I)ExchangeSocketClient</b><br />
Similarly as the <code>(I)ExchangeRestClient</code> this client allows you to access the different Websocket clients through a single access point.
</p>
<p>Having the Bitget, Kraken and OKX packages installed would allow you to use it like this:
<pre><code>var cryptoRestClient = new CryptoSocketClient(); // Either construct it or inject the ICryptoRestClient into your service
var bitgetSub = await cryptoRestClient.Bitget().SpotApi.SubscribeToTickerUpdatesAsync("ETHUSDT", data => {});
var krakenSub = await cryptoRestClient.Kraken().SpotApi.SubscribeToTickerUpdatesAsync("ETH/USD", data => {});
var okxSub = await cryptoRestClient.OKX().UnifiedApi.ExchangeData.SubscribeToTickerUpdatesAsync("ETH-USDT", data => {});</code></pre>
<p>For example accessing the Bitget, Kraken and OKX API's could be done like this:
<pre><code>var exchangeSocketClient = new ExchangeSocketClient(); // Either construct it or inject the IExchangeSocketClient into your service after having called 'AddCryptoClients()' during service regirations
var bitgetSub = await exchangeSocketClient.Bitget.SpotApi.SubscribeToTickerUpdatesAsync("ETHUSDT", data => {});
var krakenSub = await exchangeSocketClient.Kraken.SpotApi.SubscribeToTickerUpdatesAsync("ETH/USD", data => {});
var okxSub = await exchangeSocketClient.OKX.UnifiedApi.ExchangeData.SubscribeToTickerUpdatesAsync("ETH-USDT", data => {});</code></pre>
</p>
<p><b>ISpotClient</b><br />
The ISpotClient is a REST API client interface implemented by each library which implements a Spot trading API. It provided a common way of doing basic operations on the Spot market, for example getting ticker or trade data, but also placing and retrieving orders. Because this interface is implemented for each exchange with a Spot market the interface is relatively basic, only exposing methods that are supported by all the APIs.
The <code>ISpotClient</code> is a REST API client interface implemented by each library which implements a Spot trading API. It provided a common way of doing basic operations on the Spot market, for example getting ticker or trade data, but also placing and retrieving orders. Because this interface is implemented for each exchange with a Spot market the interface is relatively basic, only exposing methods that are supported by all the APIs.
</p>
<p>
The ISpotClient is added to the service collection when using <a href="#idocs_di">dependency injection</a>. Alternatively it can be accessed for a specific client by calling the `CommonSpotClient` property on the Spot sub-API of a client:
The <code>ISpotClient</code> is added to the service collection when using <a href="#idocs_di">dependency injection</a>. Alternatively it can be accessed for a specific client by calling the `CommonSpotClient` property on the Spot sub-API of a client:
<pre><code>var spotClient = restClient.SpotApi.CommonSpotClient;</code></pre>
</p>
</section>
@@ -1383,7 +1478,10 @@ options.ApiCredentials = new ApiCredentials("YOUR PUBLIC KEY", "YOUR PRIVATE KEY
<div class="tab-wrap">
<ul class="nav nav-tabs" id="options" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="options-binance-tab" data-toggle="tab" href="#options-binance" role="tab" aria-controls="options-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="options-cc-tab" data-toggle="tab" href="#options-cc" role="tab" aria-controls="options-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="options-binance-tab" data-toggle="tab" href="#options-binance" role="tab" aria-controls="options-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="options-bingx-tab" data-toggle="tab" href="#options-bingx" role="tab" aria-controls="options-bingx" aria-selected="false">BingX</a>
@@ -1420,7 +1518,16 @@ options.ApiCredentials = new ApiCredentials("YOUR PUBLIC KEY", "YOUR PRIVATE KEY
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="options-binance" role="tabpanel" aria-labelledby="options-binance-tab">
<div class="tab-pane fade show active" id="options-cc" role="tabpanel" aria-labelledby="options-cc-tab">
<pre><code>builder.Services.AddCryptoClients(globalOptions => {
globalOptions.RequestTimeout = TimeSpan.FromSeconds(30);
},
// Exchange specific options can be provided as well
bybitRestOptions: bybitOptions => {
// Set options specific for the Bybit rest client here
});</code></pre>
</div>
<div class="tab-pane fade" id="options-binance" role="tabpanel" aria-labelledby="options-binance-tab">
<pre><code>builder.Services.AddBinance(
restOptions => {
restOptions.RequestTimeout = TimeSpan.FromSeconds(30);
@@ -1533,7 +1640,10 @@ options.ApiCredentials = new ApiCredentials("YOUR PUBLIC KEY", "YOUR PRIVATE KEY
<div class="tab-wrap">
<ul class="nav nav-tabs" id="options-constr" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="options-binance-tab" data-toggle="tab" href="#options-constr-binance" role="tab" aria-controls="options-constr-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="options-cc-tab" data-toggle="tab" href="#options-constr-cc" role="tab" aria-controls="options-constr-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="options-binance-tab" data-toggle="tab" href="#options-constr-binance" role="tab" aria-controls="options-constr-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="options-bingx-tab" data-toggle="tab" href="#options-constr-bingx" role="tab" aria-controls="options-constr-bingx" aria-selected="false">BingX</a>
@@ -1570,7 +1680,13 @@ options.ApiCredentials = new ApiCredentials("YOUR PUBLIC KEY", "YOUR PRIVATE KEY
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="options-constr-binance" role="tabpanel" aria-labelledby="options-binance-tab">
<div class="tab-pane fade show active" id="options-constr-cc" role="tabpanel" aria-labelledby="options-cc-tab">
<pre><code>var exchangeRestClient = new ExchangeRestClient(opts =>
{
opts.RequestTimeout = TimeSpan.FromSeconds(30);
});</code></pre>
</div>
<div class="tab-pane fade" id="options-constr-binance" role="tabpanel" aria-labelledby="options-binance-tab">
<pre><code>var binanceRestClient = new BinanceRestClient(opts =>
{
opts.RequestTimeout = TimeSpan.FromSeconds(30);
@@ -1796,7 +1912,7 @@ var client = new OKXRestClient();</code></pre>
</tr>
<tr>
<td>ApiCredentials</td>
<td>The credentials to use for private endpoints and streams. See <a href="idocs_auth">Authorization</a> for more info</td>
<td>The credentials to use for private endpoints and streams. See <a href="idocs_auth">Authorization</a> for more info. For CryptoClients this option allows the setting of ApiCredentials for all exchanges.</td>
<td><code>null</code></td>
</tr>
<tr>
@@ -1841,6 +1957,16 @@ var client = new OKXRestClient();</code></pre>
<td>The interval of how often the time synchronization between client and server should be executed</td>
<td><code>TimeSpan.FromHours(1)</code></td>
</tr>
<tr>
<td>CachingEnabled</td>
<td>Whether or not client side caching should be enabled for GET requests, see <a href="#idocs_caching">Caching</a></td>
<td><code>false</code></td>
</tr>
<tr>
<td>CachingMaxAge</td>
<td>The max age of data to return from the cache. If the same data is requested and the data is available in the client side cache and not older than this value the cached value is returned, else a new request will be done</td>
<td><code>TimeSpan.FromSeconds(5)</code></td>
</tr>
<tr>
<td>[API].ApiCredentials</td>
<td>Same as the in the base options, allows overriding per sub-API</td>
@@ -2283,7 +2409,10 @@ var binanceClient = new BinanceRestClient(new HttpClient(), logFactory, options
<div class="tab-wrap">
<ul class="nav nav-tabs" id="limit" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="limit-binance-tab" data-toggle="tab" href="#limit-binance" role="tab" aria-controls="limit-binance" aria-selected="true">Binance</a>
<a class="nav-link active" id="limit-cc-tab" data-toggle="tab" href="#limit-cc" role="tab" aria-controls="limit-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="limit-binance-tab" data-toggle="tab" href="#limit-binance" role="tab" aria-controls="limit-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="limit-kraken-tab" data-toggle="tab" href="#limit-kraken" role="tab" aria-controls="limit-kraken" aria-selected="false">Kraken</a>
@@ -2293,7 +2422,18 @@ var binanceClient = new BinanceRestClient(new HttpClient(), logFactory, options
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="limit-binance" role="tabpanel" aria-labelledby="limit-binance-tab">
<div class="tab-pane fade show active" id="limit-cc" role="tabpanel" aria-labelledby="limit-cc-tab">
<pre><code>services.AddCryptoClients(x =>
x.RatelimiterEnabled = true;
x.RateLimitingBehaviour = RateLimitingBehaviour.Wait;
});</code></pre>
<p>To be notified of when a rate limit is hit the static <code>Exchanges.RateLimiter</code> exposes an event which triggers when a rate limit is reached</p>
<pre><code>BinanceExchange.RateLimiter.RateLimitTriggered += (rateLimitEvent) => Console.WriteLine("Limit triggered: " + rateLimitEvent);
// Output: Limit triggered: RateLimitEvent { ApiLimit = Spot Socket, LimitDescription = Limit of 6000 per 00:01:00, RequestDefinition = GET 1, Host = wss://ws-api.binance.com, Current = 5752, RequestWeight = 250, Limit = 6000, TimePeriod = 00:01:00, DelayTime = 00:00:38.7784145, Behaviour = Wait }
</code></pre>
</div>
<div class="tab-pane fade" id="limit-binance" role="tabpanel" aria-labelledby="limit-binance-tab">
<pre><code>services.AddBinance(x =>
x.RatelimiterEnabled = true;
x.RateLimitingBehaviour = RateLimitingBehaviour.Wait;
@@ -2345,6 +2485,30 @@ var binanceClient = new BinanceRestClient(new HttpClient(), logFactory, options
</div>
</div>
</section>
<section id="idocs_caching">
<h2>Caching</h2>
<p>
Every REST API client based on the CryptoExchange.Net base library automatically supports caching of GET HTTP requests. A few advantages of caching:
<ol>
<li>Performance improvement, data response will be much faster as no roundtrip to the server is needed</li>
<li>Reduced resource usage, returning data from the cache uses less resources than reading the server response, though there is some memory overhead</li>
<li>Prevent rate limiting, the cache can be queried as many times as you like without having to worry about getting rate limited by the server</li>
</ol>
<div class="alert alert-info">Caching is only applied for successful GET requests as GET requests by definition should not change state. Other HTTP method (POST, DELETE, etc) generally do change state, so caching those call would prevent an action being executed.</div>
</p>
<p>
To enable caching for GET requests set <code>CachingEnabled</code> to <code>true</code> in the client options. Optionally set the <code>CachingMaxAge</code> option to the desired value (default is 5 seconds).
</p>
<p>
To determine whether a request has gotten the data from the server or from the local cache the <code>DataSource</code> property on the call result can inspected:
<pre><code>var result = await bitfinexRestClient.SpotApi.Account.Get30DaySummaryAndFeesAsync();
var responseSource = result.DataSource;</code></pre>
</p>
</section>
<hr class="divider">
@@ -2368,7 +2532,7 @@ var binanceClient = new BinanceRestClient(new HttpClient(), logFactory, options
<ul class="nav nav-tabs" id="example-symbols" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-symbols-general-tab" data-toggle="tab" href="#example-symbols-general" role="tab" aria-controls="example-symbols-general" aria-selected="true">CryptoRestClient</a>
<a class="nav-link active" id="example-symbols-general-tab" data-toggle="tab" href="#example-symbols-general" role="tab" aria-controls="example-symbols-general" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="example-symbols-binance-tab" data-toggle="tab" href="#example-symbols-binance" role="tab" aria-controls="example-symbols-binance" aria-selected="false">Binance</a>
@@ -2406,9 +2570,8 @@ var binanceClient = new BinanceRestClient(new HttpClient(), logFactory, options
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="example-symbols-general" role="tabpanel" aria-labelledby="example-symbols-general-tab">
<pre><code>// Name of the exchange can be whatever exchange library you have installed, for example Binance, Bybit, Kraken etc
var spotClient = cryptoRestClient.SpotClient("[Name of the exchange]");
await spotClient.GetSymbolsAsync();</code></pre>
<pre><code>// This example uses Binance, but can be any exchange support. For example Bybit, Kraken, Kucoin etc
await exchangeRestClient.Binance.SpotApi.ExchangeData.GetExchangeInfoAsync();</code></pre>
</div>
<div class="tab-pane fade" id="example-symbols-binance" role="tabpanel" aria-labelledby="example-symbols-binance-tab">
<pre><code>await binanceClient.SpotApi.ExchangeData.GetExchangeInfoAsync();</code></pre>
@@ -2462,7 +2625,7 @@ await spotClient.GetSymbolsAsync();</code></pre>
<ul class="nav nav-tabs" id="example-ticker" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-ticker-general-tab" data-toggle="tab" href="#example-ticker-general" role="tab" aria-controls="example-ticker-general" aria-selected="true">CryptoRestClient</a>
<a class="nav-link active" id="example-ticker-general-tab" data-toggle="tab" href="#example-ticker-general" role="tab" aria-controls="example-ticker-general" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="example-ticker-binance-tab" data-toggle="tab" href="#example-ticker-binance" role="tab" aria-controls="example-ticker-binance" aria-selected="true">Binance</a>
@@ -2500,9 +2663,8 @@ await spotClient.GetSymbolsAsync();</code></pre>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="example-ticker-general" role="tabpanel" aria-labelledby="example-ticker-general-tab">
<pre><code>// Name of the exchange can be whatever exchange library you have installed, for example Binance, Bybit, Kraken etc
var spotClient = cryptoRestClient.SpotClient("[Name of the exchange]");
await spotClient.GetTickerAsync(spotClient.GetSymbolName("BTC", "USDT"));</code></pre>
<pre><code>// This example uses Binance, but can be any exchange support. For example Bybit, Kraken, Kucoin etc
await exchangeRestClient.Binance.SpotApi.ExchangeData.GetTickerAsync(spotClient.GetSymbolName("BTC", "USDT"));</code></pre>
</div>
<div class="tab-pane fade" id="example-ticker-binance" role="tabpanel" aria-labelledby="example-ticker-binance-tab">
<pre><code>await binanceClient.SpotApi.ExchangeData.GetTickerAsync("BTCUSDT");</code></pre>
@@ -2556,7 +2718,7 @@ await spotClient.GetTickerAsync(spotClient.GetSymbolName("BTC", "USDT"));</code>
<ul class="nav nav-tabs" id="example-balances" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-balances-general-tab" data-toggle="tab" href="#example-balances-general" role="tab" aria-controls="example-balances-general" aria-selected="true">CryptoRestClient</a>
<a class="nav-link active" id="example-balances-general-tab" data-toggle="tab" href="#example-balances-general" role="tab" aria-controls="example-balances-general" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="example-balances-binance-tab" data-toggle="tab" href="#example-balances-binance" role="tab" aria-controls="example-balances-binance" aria-selected="true">Binance</a>
@@ -2594,9 +2756,8 @@ await spotClient.GetTickerAsync(spotClient.GetSymbolName("BTC", "USDT"));</code>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="example-balances-general" role="tabpanel" aria-labelledby="example-balances-general-tab">
<pre><code>// Name of the exchange can be whatever exchange library you have installed, for example Binance, Bybit, Kraken etc
var spotClient = cryptoRestClient.SpotClient("[Name of the exchange]");
await spotClient.GetBalancesAsync();</code></pre>
<pre><code>// This example uses Binance, but can be any exchange support. For example Bybit, Kraken, Kucoin etc
await exchangeRestClient.Binance.SpotApi.Account.GetBalancesAsync();</code></pre>
</div>
<div class="tab-pane fade" id="example-balances-binance" role="tabpanel" aria-labelledby="example-balances-binance-tab">
<pre><code>await binanceClient.SpotApi.Account.GetBalancesAsync();</code></pre>
@@ -2654,7 +2815,7 @@ var result = await huobiClient.SpotApi.Account.GetBalancesAsync();</code></pre>
<ul class="nav nav-tabs" id="example-place" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-place-general-tab" data-toggle="tab" href="#example-place-general" role="tab" aria-controls="example-place-general" aria-selected="true">CryptoRestClient</a>
<a class="nav-link active" id="example-place-general-tab" data-toggle="tab" href="#example-place-general" role="tab" aria-controls="example-place-general" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="example-place-binance-tab" data-toggle="tab" href="#example-place-binance" role="tab" aria-controls="example-place-binance" aria-selected="true">Binance</a>
@@ -2692,9 +2853,8 @@ var result = await huobiClient.SpotApi.Account.GetBalancesAsync();</code></pre>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="example-place-general" role="tabpanel" aria-labelledby="example-place-general-tab">
<pre><code>// Name of the exchange can be whatever exchange library you have installed, for example Binance, Bybit, Kraken etc
var spotClient = cryptoRestClient.SpotClient("[Name of the exchange]");
await spotClient.PlaceOrderAsync(spotClient.GetSymbolName("BTC", "USDT"), CommonOrderSide.Buy, CommonOrderType.Limit, 0.1m, price: 50000);</code></pre>
<pre><code>// This example uses Binance, but can be any exchange support. For example Bybit, Kraken, Kucoin etc
await exchangeRestClient.Binance.SpotApi.Trading.PlaceOrderAsync("BTCUSDT", OrderSide.Buy, SpotOrderType.Limit, 0.1m, price: 50000, timeInForce: TimeInForce.GoodTillCanceled);</code></pre>
</div>
<div class="tab-pane fade" id="example-place-binance" role="tabpanel" aria-labelledby="example-place-binance-tab">
<pre><code>await binanceClient.SpotApi.Trading.PlaceOrderAsync("BTCUSDT", OrderSide.Buy, SpotOrderType.Limit, 0.1m, price: 50000, timeInForce: TimeInForce.GoodTillCanceled);</code></pre>
@@ -2751,8 +2911,11 @@ var result = await huobiClient.SpotApi.Trading.PlaceOrderAsync(account.Id, "BTCU
<div>
<ul class="nav nav-tabs" id="example-stream-ticker" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-stream-ticker-cc-tab" data-toggle="tab" href="#example-stream-ticker-cc" role="tab" aria-controls="example-stream-ticker-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-stream-ticker-binance-tab" data-toggle="tab" href="#example-stream-ticker-binance" role="tab" aria-controls="example-stream-ticker-binance" aria-selected="true">Binance</a>
<a class="nav-link" id="example-stream-ticker-binance-tab" data-toggle="tab" href="#example-stream-ticker-binance" role="tab" aria-controls="example-stream-ticker-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="example-stream-ticker-bingx-tab" data-toggle="tab" href="#example-stream-ticker-bingx" role="tab" aria-controls="example-stream-ticker-bingx" aria-selected="false">BingX</a>
@@ -2786,7 +2949,13 @@ var result = await huobiClient.SpotApi.Trading.PlaceOrderAsync(account.Id, "BTCU
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="example-stream-ticker-binance" role="tabpanel" aria-labelledby="example-stream-ticker-binance-tab">
<div class="tab-pane fade show active" id="example-stream-ticker-cc" role="tabpanel" aria-labelledby="example-stream-ticker-cc-tab">
<pre><code>// This example uses Binance, but can be any exchange support. For example Bybit, Kraken, Kucoin etc
await exchangeSocketClient.Binance.SpotApi.ExchangeData.SubscribeToTickerUpdatesAsync("ETHUSDT", data => {
// Handle update
});</code></pre>
</div>
<div class="tab-pane fade" id="example-stream-ticker-binance" role="tabpanel" aria-labelledby="example-stream-ticker-binance-tab">
<pre><code>await binanceSocketClient.SpotApi.ExchangeData.SubscribeToTickerUpdatesAsync("ETHUSDT", data => {
// Handle update
});</code></pre>
@@ -2860,8 +3029,11 @@ var result = await huobiClient.SpotApi.Trading.PlaceOrderAsync(account.Id, "BTCU
<div>
<ul class="nav nav-tabs" id="example-stream-order" role="tablist" style="margin-bottom: -16px;">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-stream-order-cc-tab" data-toggle="tab" href="#example-stream-order-cc" role="tab" aria-controls="example-stream-order-cc" aria-selected="true">CryptoClients</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link active" id="example-stream-order-binance-tab" data-toggle="tab" href="#example-stream-order-binance" role="tab" aria-controls="example-stream-order-binance" aria-selected="true">Binance</a>
<a class="nav-link" id="example-stream-order-binance-tab" data-toggle="tab" href="#example-stream-order-binance" role="tab" aria-controls="example-stream-order-binance" aria-selected="true">Binance</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="example-stream-order-bitfinex-tab" data-toggle="tab" href="#example-stream-order-bingx" role="tab" aria-controls="example-stream-order-bingx" aria-selected="false">BingX</a>
@@ -2895,6 +3067,27 @@ var result = await huobiClient.SpotApi.Trading.PlaceOrderAsync(account.Id, "BTCU
</li>
</ul>
<div class="tab-content my-3" id="myTabContent">
<div class="tab-pane fade show active" id="example-stream-order-cc" role="tabpanel" aria-labelledby="example-stream-order-cc-tab">
<pre><code>// This example uses Binance, but can be any exchange support. For example Bybit, Kraken, Kucoin etc
// Retrieve the listen key
var listenKey = await exchangeRestClient.Binance.SpotApi.Account.StartUserStreamAsync();
// Subscribe using the key
await exchangeSocketClient.Binance.SpotApi.Account.SubscribeToUserDataUpdatesAsync(listenKey.Data, data => {
// Handle update
}, null, null, null);
// The listen key will stay valid for 60 minutes, after this no updates will be send anymore
// To extend the life time of the listen key it is recommended to call the KeepAliveUserStreamAsync method every 30 minutes
_ = Task.Run(async () => {
while (true)
{
await Task.Delay(Timespan.FromMinutes(30));
await exchangeRestClient.Binance.SpotApi.Account.KeepAliveUserStreamAsync(listenKey.Data);
}
});</code></pre>
</div>
<div class="tab-pane fade show active" id="example-stream-order-binance" role="tabpanel" aria-labelledby="example-stream-order-binance-tab">
<pre><code>// Retrieve the listen key
var listenKey = await binanceClient.SpotApi.Account.StartUserStreamAsync();
@@ -3018,17 +3211,17 @@ _ = Task.Run(async () => {
</p>
<pre><code class="language-csharp">using CryptoExchange.Net.Interfaces;
using CryptoClients.Net.Enums;
using CryptoClients.Net.Interfaces;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddBitfinex();
builder.Services.AddBitget();
builder.Services.AddKraken();
builder.Services.AddCryptoClients();
var app = builder.Build();
app.MapGet("Ticker/{exchange}/{baseAsset}/{quoteAsset}", async ([FromServices] ICryptoRestClient client, string exchange, string baseAsset, string quoteAsset) =>
app.MapGet("Ticker/{exchange}/{baseAsset}/{quoteAsset}", async ([FromServices] IExchangeRestClient client, Exchange exchange, string baseAsset, string quoteAsset) =>
{
var spotClient = client.SpotClient(exchange)!;
var spotClient = client.GetUnifiedSpotClient(exchange)!;
var result = await spotClient.GetTickerAsync(spotClient.GetSymbolName(baseAsset, quoteAsset));
return result.Data;
});