diff --git a/CryptoExchange.Net.UnitTests/ExchangeClientTests.cs b/CryptoExchange.Net.UnitTests/ExchangeClientTests.cs index 9a04522..2ba836a 100644 --- a/CryptoExchange.Net.UnitTests/ExchangeClientTests.cs +++ b/CryptoExchange.Net.UnitTests/ExchangeClientTests.cs @@ -174,7 +174,7 @@ namespace CryptoExchange.Net.UnitTests TestImplementation client; if (withOptions) { - var options = new ExchangeOptions() + var options = new ClientOptions() { ApiCredentials = new ApiCredentials("Test", "Test2"), LogVerbosity = verbosity @@ -219,7 +219,7 @@ namespace CryptoExchange.Net.UnitTests factory.Setup(c => c.Create(It.IsAny())) .Returns(request.Object); - TestImplementation client = credentials ? new TestImplementation(new ExchangeOptions() { ApiCredentials = new ApiCredentials("Test", "Test2") }) : new TestImplementation(); + TestImplementation client = credentials ? new TestImplementation(new ClientOptions() { ApiCredentials = new ApiCredentials("Test", "Test2") }) : new TestImplementation(); client.RequestFactory = factory.Object; return client; } diff --git a/CryptoExchange.Net.UnitTests/TestImplementation.cs b/CryptoExchange.Net.UnitTests/TestImplementation.cs index f0a0ec6..8ac145e 100644 --- a/CryptoExchange.Net.UnitTests/TestImplementation.cs +++ b/CryptoExchange.Net.UnitTests/TestImplementation.cs @@ -6,11 +6,11 @@ using CryptoExchange.Net.Objects; namespace CryptoExchange.Net.UnitTests { - public class TestImplementation: ExchangeClient + public class TestImplementation: RestClient { - public TestImplementation(): base(new ExchangeOptions(), null) { } + public TestImplementation(): base(new ClientOptions(), null) { } - public TestImplementation(ExchangeOptions exchangeOptions) : base(exchangeOptions, exchangeOptions.ApiCredentials == null ? null : new TestAuthProvider(exchangeOptions.ApiCredentials)) + public TestImplementation(ClientOptions exchangeOptions) : base(exchangeOptions, exchangeOptions.ApiCredentials == null ? null : new TestAuthProvider(exchangeOptions.ApiCredentials)) { } diff --git a/CryptoExchange.Net/BaseClient.cs b/CryptoExchange.Net/BaseClient.cs new file mode 100644 index 0000000..325bc73 --- /dev/null +++ b/CryptoExchange.Net/BaseClient.cs @@ -0,0 +1,244 @@ +using CryptoExchange.Net.Attributes; +using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.Logging; +using CryptoExchange.Net.Objects; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace CryptoExchange.Net +{ + public abstract class BaseClient + { + protected string baseAddress; + protected Log log; + protected ApiProxy apiProxy; + protected AuthenticationProvider authProvider; + + protected static int lastId; + protected static object idLock = new object(); + + private static readonly JsonSerializer defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings() + { + DateTimeZoneHandling = DateTimeZoneHandling.Utc + }); + + public BaseClient(ExchangeOptions options, AuthenticationProvider authenticationProvider) + { + log = new Log(); + authProvider = authenticationProvider; + Configure(options); + } + + /// + /// Configure the client using the provided options + /// + /// Options + protected virtual void Configure(ExchangeOptions exchangeOptions) + { + log.UpdateWriters(exchangeOptions.LogWriters); + log.Level = exchangeOptions.LogVerbosity; + + baseAddress = exchangeOptions.BaseAddress; + apiProxy = exchangeOptions.Proxy; + if (apiProxy != null) + log.Write(LogVerbosity.Info, $"Setting api proxy to {exchangeOptions.Proxy.Host}:{exchangeOptions.Proxy.Port}"); + } + + /// + /// Set the authentication provider + /// + /// + protected void SetAuthenticationProvider(AuthenticationProvider authentictationProvider) + { + log.Write(LogVerbosity.Debug, "Setting api credentials"); + authProvider = authentictationProvider; + } + + protected CallResult Deserialize(string data, bool checkObject = true, JsonSerializer serializer = null) where T : class + { + if (serializer == null) + serializer = defaultSerializer; + + try + { + var obj = JToken.Parse(data); + if (checkObject && log.Level == LogVerbosity.Debug) + { + try + { + if (obj is JObject o) + { + CheckObject(typeof(T), o); + } + else + { + var ary = (JArray)obj; + if (ary.HasValues && ary[0] is JObject jObject) + CheckObject(typeof(T).GetElementType(), jObject); + } + } + catch (Exception e) + { + log.Write(LogVerbosity.Debug, "Failed to check response data: " + e.Message); + } + } + + return new CallResult(obj.ToObject(serializer), null); + } + catch (JsonReaderException jre) + { + var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}. Received data: {data}"; + log.Write(LogVerbosity.Error, info); + return new CallResult(null, new DeserializeError(info)); + } + catch (JsonSerializationException jse) + { + var info = $"Deserialize JsonSerializationException: {jse.Message}. Received data: {data}"; + log.Write(LogVerbosity.Error, info); + return new CallResult(null, new DeserializeError(info)); + } + catch (Exception ex) + { + var info = $"Deserialize Unknown Exception: {ex.Message}. Received data: {data}"; + log.Write(LogVerbosity.Error, info); + return new CallResult(null, new DeserializeError(info)); + } + } + + private void CheckObject(Type type, JObject obj) + { + if (type.GetCustomAttribute(true) != null) + // If type has a custom JsonConverter we assume this will handle property mapping + return; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + return; + + if (!obj.HasValues && type != typeof(object)) + { + log.Write(LogVerbosity.Warning, $"Expected `{type.Name}`, but received object was empty"); + return; + } + + bool isDif = false; + var properties = new List(); + var props = type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.FlattenHierarchy); + foreach (var prop in props) + { + var attr = prop.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault(); + var ignore = prop.GetCustomAttributes(typeof(JsonIgnoreAttribute), false).FirstOrDefault(); + if (ignore != null) + continue; + + properties.Add(attr == null ? prop.Name : ((JsonPropertyAttribute)attr).PropertyName); + } + foreach (var token in obj) + { + var d = properties.SingleOrDefault(p => p == token.Key); + if (d == null) + { + d = properties.SingleOrDefault(p => p.ToLower() == token.Key.ToLower()); + if (d == null && !(type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>))) + { + log.Write(LogVerbosity.Warning, $"Local object doesn't have property `{token.Key}` expected in type `{type.Name}`"); + isDif = true; + continue; + } + } + properties.Remove(d); + + var propType = GetProperty(d, props)?.PropertyType; + if (propType == null) + continue; + if (!IsSimple(propType) && propType != typeof(DateTime)) + { + if (propType.IsArray && token.Value.HasValues && ((JArray)token.Value).Any() && ((JArray)token.Value)[0] is JObject) + CheckObject(propType.GetElementType(), (JObject)token.Value[0]); + else if (token.Value is JObject) + CheckObject(propType, (JObject)token.Value); + } + } + + foreach (var prop in properties) + { + var propInfo = props.First(p => p.Name == prop || + ((JsonPropertyAttribute)p.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault())?.PropertyName == prop); + var optional = propInfo.GetCustomAttributes(typeof(JsonOptionalPropertyAttribute), false).FirstOrDefault(); + if (optional != null) + continue; + + isDif = true; + log.Write(LogVerbosity.Warning, $"Local object has property `{prop}` but was not found in received object of type `{type.Name}`"); + } + + if (isDif) + log.Write(LogVerbosity.Debug, "Returned data: " + obj); + } + + private PropertyInfo GetProperty(string name, PropertyInfo[] props) + { + foreach (var prop in props) + { + var attr = prop.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault(); + if (attr == null) + { + if (prop.Name.ToLower() == name.ToLower()) + return prop; + } + else + { + if (((JsonPropertyAttribute)attr).PropertyName == name) + return prop; + } + } + return null; + } + + private bool IsSimple(Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + // nullable type, check if the nested type is simple. + return IsSimple(type.GetGenericArguments()[0]); + } + return type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal); + } + + protected int NextId() + { + lock (idLock) + { + lastId += 1; + return lastId; + } + } + + protected static string FillPathParameter(string endpoint, params string[] values) + { + foreach (var value in values) + { + int index = endpoint.IndexOf("{}", StringComparison.Ordinal); + if (index >= 0) + { + endpoint = endpoint.Remove(index, 2); + endpoint = endpoint.Insert(index, value); + } + } + return endpoint; + } + + public virtual void Dispose() + { + authProvider?.Credentials?.Dispose(); + log.Write(LogVerbosity.Debug, "Disposing exchange client"); + } + } +} diff --git a/CryptoExchange.Net/ExtensionMethods.cs b/CryptoExchange.Net/ExtensionMethods.cs index 3e6d6b9..b66bd70 100644 --- a/CryptoExchange.Net/ExtensionMethods.cs +++ b/CryptoExchange.Net/ExtensionMethods.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Runtime.InteropServices; using System.Security; @@ -30,16 +31,16 @@ namespace CryptoExchange.Net parameters.Add(key, value); } - public static string CreateParamString(this Dictionary parameters) + public static string CreateParamString(this Dictionary parameters, bool urlEncodeValues) { - var uriString = "?"; + var uriString = ""; var arraysParameters = parameters.Where(p => p.Value.GetType().IsArray).ToList(); foreach (var arrayEntry in arraysParameters) { - uriString += $"{string.Join("&", ((object[])arrayEntry.Value).Select(v => $"{arrayEntry.Key}[]={v}"))}&"; + uriString += $"{string.Join("&", ((object[])(urlEncodeValues ? WebUtility.UrlEncode(arrayEntry.Value.ToString()) : arrayEntry.Value)).Select(v => $"{arrayEntry.Key}[]={v}"))}&"; } - uriString += $"{string.Join("&", parameters.Where(p => !p.Value.GetType().IsArray).Select(s => $"{s.Key}={s.Value}"))}"; + uriString += $"{string.Join("&", parameters.Where(p => !p.Value.GetType().IsArray).Select(s => $"{s.Key}={(urlEncodeValues ? WebUtility.UrlEncode(s.Value.ToString()) : s.Value)}"))}"; uriString = uriString.TrimEnd('&'); return uriString; } diff --git a/CryptoExchange.Net/Interfaces/IWebsocket.cs b/CryptoExchange.Net/Interfaces/IWebsocket.cs index 1a122ab..8f4eef9 100644 --- a/CryptoExchange.Net/Interfaces/IWebsocket.cs +++ b/CryptoExchange.Net/Interfaces/IWebsocket.cs @@ -7,13 +7,15 @@ namespace CryptoExchange.Net.Interfaces { public interface IWebsocket: IDisposable { - void SetEnabledSslProtocols(SslProtocols protocols); - event Action OnClose; event Action OnMessage; event Action OnError; event Action OnOpen; + int Id { get; } + bool ShouldReconnect { get; set; } + Func DataInterpreter { get; set; } + DateTime? DisconnectTime { get; set; } string Url { get; } WebSocketState SocketState { get; } bool IsClosed { get; } @@ -21,6 +23,7 @@ namespace CryptoExchange.Net.Interfaces bool PingConnection { get; set; } TimeSpan PingInterval { get; set; } + void SetEnabledSslProtocols(SslProtocols protocols); Task Connect(); void Send(string data); Task Close(); diff --git a/CryptoExchange.Net/Objects/ByteOrderComparer.cs b/CryptoExchange.Net/Objects/ByteOrderComparer.cs new file mode 100644 index 0000000..4506ef9 --- /dev/null +++ b/CryptoExchange.Net/Objects/ByteOrderComparer.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.Objects +{ + public class ByteOrderComparer : IComparer + { + public int Compare(byte[] x, byte[] 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 && y != null) return -1; + if (x != null && y == null) return 1; + + // Both arrays are non-null. Find the shorter + // of the two lengths. + int bytesToCompare = Math.Min(x.Length, y.Length); + + // Compare the bytes. + for (int index = 0; index < bytesToCompare; ++index) + { + // The x and y bytes. + byte xByte = x[index]; + byte yByte = y[index]; + + // Compare result. + int compareResult = Comparer.Default.Compare(xByte, yByte); + + // If not the same, then return the result of the + // comparison of the bytes, as they were the same + // up until now. + if (compareResult != 0) return compareResult; + + // They are the same, continue. + } + + // The first n bytes are the same. Compare lengths. + // If the lengths are the same, the arrays + // are the same. + if (x.Length == y.Length) return 0; + + // Compare lengths. + return x.Length < y.Length ? -1 : 1; + } + } +} diff --git a/CryptoExchange.Net/Objects/Constants.cs b/CryptoExchange.Net/Objects/Constants.cs new file mode 100644 index 0000000..95e3a7f --- /dev/null +++ b/CryptoExchange.Net/Objects/Constants.cs @@ -0,0 +1,13 @@ +namespace CryptoExchange.Net.Objects +{ + public class Constants + { + public const string GetMethod = "GET"; + public const string PostMethod = "POST"; + public const string DeleteMethod = "DELETE"; + public const string PutMethod = "PUT"; + + public const string JsonContentHeader = "application/json"; + public const string FormContentHeader = "application/x-www-form-urlencoded"; + } +} diff --git a/CryptoExchange.Net/Objects/ExchangeOptions.cs b/CryptoExchange.Net/Objects/ExchangeOptions.cs index d3e5670..6a2d796 100644 --- a/CryptoExchange.Net/Objects/ExchangeOptions.cs +++ b/CryptoExchange.Net/Objects/ExchangeOptions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Logging; @@ -35,8 +36,11 @@ namespace CryptoExchange.Net.Objects /// /// The log writers /// - public List LogWriters { get; set; } = new List() {new DebugTextWriter()}; + public List LogWriters { get; set; } = new List() {new DebugTextWriter()}; + } + public class ClientOptions: ExchangeOptions + { /// /// List of ratelimiters to use /// @@ -47,4 +51,12 @@ namespace CryptoExchange.Net.Objects /// public RateLimitingBehaviour RateLimitingBehaviour { get; set; } = RateLimitingBehaviour.Wait; } + + public class SocketClientOptions: ExchangeOptions + { + /// + /// Time to wait between reconnect attempts + /// + public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(2); + } } diff --git a/CryptoExchange.Net/ExchangeClient.cs b/CryptoExchange.Net/RestClient.cs similarity index 53% rename from CryptoExchange.Net/ExchangeClient.cs rename to CryptoExchange.Net/RestClient.cs index 03467df..0136930 100644 --- a/CryptoExchange.Net/ExchangeClient.cs +++ b/CryptoExchange.Net/RestClient.cs @@ -6,11 +6,9 @@ using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; -using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Web; -using CryptoExchange.Net.Attributes; using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Logging; @@ -18,23 +16,18 @@ using CryptoExchange.Net.Objects; using CryptoExchange.Net.RateLimiter; using CryptoExchange.Net.Requests; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace CryptoExchange.Net { - public abstract class ExchangeClient : IDisposable + public abstract class RestClient: BaseClient { public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); - protected string baseAddress; - protected Log log; - protected ApiProxy apiProxy; protected RateLimitingBehaviour rateLimitBehaviour; protected PostParameters postParametersPosition = PostParameters.InBody; protected RequestBodyFormat requestBodyFormat = RequestBodyFormat.Json; - protected AuthenticationProvider authProvider; private List rateLimiters; private static readonly JsonSerializer defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings() @@ -42,10 +35,8 @@ namespace CryptoExchange.Net DateTimeZoneHandling = DateTimeZoneHandling.Utc }); - protected ExchangeClient(ExchangeOptions exchangeOptions, AuthenticationProvider authenticationProvider) + protected RestClient(ClientOptions exchangeOptions, AuthenticationProvider authenticationProvider): base(exchangeOptions, authenticationProvider) { - log = new Log(); - authProvider = authenticationProvider; Configure(exchangeOptions); } @@ -53,16 +44,8 @@ namespace CryptoExchange.Net /// Configure the client using the provided options /// /// Options - protected void Configure(ExchangeOptions exchangeOptions) + protected void Configure(ClientOptions exchangeOptions) { - log.UpdateWriters(exchangeOptions.LogWriters); - log.Level = exchangeOptions.LogVerbosity; - - baseAddress = exchangeOptions.BaseAddress; - apiProxy = exchangeOptions.Proxy; - if (apiProxy != null) - log.Write(LogVerbosity.Info, $"Setting api proxy to {exchangeOptions.Proxy.Host}:{exchangeOptions.Proxy.Port}"); - rateLimitBehaviour = exchangeOptions.RateLimitingBehaviour; rateLimiters = new List(); foreach (var rateLimiter in exchangeOptions.RateLimiters) @@ -86,16 +69,6 @@ namespace CryptoExchange.Net rateLimiters.Clear(); } - /// - /// Set the authentication provider - /// - /// - protected void SetAuthenticationProvider(AuthenticationProvider authentictationProvider) - { - log.Write(LogVerbosity.Debug, "Setting api credentials"); - authProvider = authentictationProvider; - } - /// /// Ping to see if the server is reachable /// @@ -130,7 +103,7 @@ namespace CryptoExchange.Net return new CallResult(0, new CantConnectError() { Message = "Ping failed: " + reply.Status }); } - protected virtual async Task> ExecuteRequest(Uri uri, string method = "GET", Dictionary parameters = null, bool signed = false) where T : class + protected virtual async Task> ExecuteRequest(Uri uri, string method = Constants.GetMethod, Dictionary parameters = null, bool signed = false) where T : class { log.Write(LogVerbosity.Debug, $"Creating request for " + uri); if (signed && authProvider == null) @@ -171,7 +144,7 @@ namespace CryptoExchange.Net paramString = paramString.Trim(','); } - log.Write(LogVerbosity.Debug, $"Sending {method} {(signed ? "signed" : "")} request to {request.Uri} {(paramString ?? "")}"); + log.Write(LogVerbosity.Debug, $"Sending {method}{(signed ? " signed" : "")} request to {request.Uri} {(paramString ?? "")}"); var result = await ExecuteRequest(request).ConfigureAwait(false); return result.Error != null ? new CallResult(null, result.Error) : Deserialize(result.Data); } @@ -185,12 +158,12 @@ namespace CryptoExchange.Net if(authProvider != null) parameters = authProvider.AddAuthenticationToParameters(uriString, method, parameters, signed); - if((method == "GET" || method == "DELETE" || ((method == "POST" || method == "PUT") && postParametersPosition == PostParameters.InUri)) && parameters?.Any() == true) - uriString += parameters.CreateParamString(); + if((method == Constants.GetMethod || method == Constants.DeleteMethod || (postParametersPosition == PostParameters.InUri)) && parameters?.Any() == true) + uriString += "?" + parameters.CreateParamString(true); var request = RequestFactory.Create(uriString); - request.ContentType = requestBodyFormat == RequestBodyFormat.Json ? "application/json": "application/x-www-form-urlencoded"; - request.Accept = "application/json"; + request.ContentType = requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader; + request.Accept = Constants.JsonContentHeader; request.Method = method; var headers = new Dictionary(); @@ -200,7 +173,7 @@ namespace CryptoExchange.Net foreach (var header in headers) request.Headers.Add(header.Key, header.Value); - if ((method == "POST" || method == "PUT") && postParametersPosition != PostParameters.InUri) + if ((method == Constants.PostMethod || method == Constants.PutMethod) && postParametersPosition != PostParameters.InUri) { if(parameters?.Any() == true) WriteParamBody(request, parameters); @@ -289,164 +262,5 @@ namespace CryptoExchange.Net { return new ServerError(error); } - - protected CallResult Deserialize(string data, bool checkObject = true, JsonSerializer serializer = null) where T : class - { - if (serializer == null) - serializer = defaultSerializer; - - try - { - var obj = JToken.Parse(data); - if (checkObject && log.Level == LogVerbosity.Debug) - { - try - { - if (obj is JObject o) - { - CheckObject(typeof(T), o); - } - else - { - var ary = (JArray)obj; - if (ary.HasValues && ary[0] is JObject jObject) - CheckObject(typeof(T).GetElementType(), jObject); - } - } - catch (Exception e) - { - log.Write(LogVerbosity.Debug, "Failed to check response data: " + e.Message); - } - } - - return new CallResult(obj.ToObject(serializer), null); - } - catch (JsonReaderException jre) - { - var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}. Received data: {data}"; - log.Write(LogVerbosity.Error, info); - return new CallResult(null, new DeserializeError(info)); - } - catch (JsonSerializationException jse) - { - var info = $"Deserialize JsonSerializationException: {jse.Message}. Received data: {data}"; - log.Write(LogVerbosity.Error, info); - return new CallResult(null, new DeserializeError(info)); - } - catch (Exception ex) - { - var info = $"Deserialize Unknown Exception: {ex.Message}. Received data: {data}"; - log.Write(LogVerbosity.Error, info); - return new CallResult(null, new DeserializeError(info)); - } - } - - private void CheckObject(Type type, JObject obj) - { - if (type.GetCustomAttribute(true) != null) - // If type has a custom JsonConverter we assume this will handle property mapping - return; - - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) - return; - - if (!obj.HasValues && type != typeof(object)) - { - log.Write(LogVerbosity.Warning, $"Expected `{type.Name}`, but received object was empty"); - return; - } - - bool isDif = false; - var properties = new List(); - var props = type.GetProperties(); - foreach (var prop in props) - { - var attr = prop.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault(); - var ignore = prop.GetCustomAttributes(typeof(JsonIgnoreAttribute), false).FirstOrDefault(); - if (ignore != null) - continue; - - properties.Add(attr == null ? prop.Name : ((JsonPropertyAttribute)attr).PropertyName); - } - foreach (var token in obj) - { - var d = properties.SingleOrDefault(p => p == token.Key); - if (d == null) - { - d = properties.SingleOrDefault(p => p.ToLower() == token.Key.ToLower()); - if (d == null && !(type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>))) - { - log.Write(LogVerbosity.Warning, $"Local object doesn't have property `{token.Key}` expected in type `{type.Name}`"); - isDif = true; - continue; - } - } - properties.Remove(d); - - var propType = GetProperty(d, props)?.PropertyType; - if (propType == null) - continue; - if (!IsSimple(propType) && propType != typeof(DateTime)) - { - if (propType.IsArray && token.Value.HasValues && ((JArray)token.Value).Any() && ((JArray)token.Value)[0] is JObject) - CheckObject(propType.GetElementType(), (JObject)token.Value[0]); - else if (token.Value is JObject) - CheckObject(propType, (JObject)token.Value); - } - } - - foreach (var prop in properties) - { - var propInfo = props.First(p => p.Name == prop || - ((JsonPropertyAttribute)p.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault())?.PropertyName == prop); - var optional = propInfo.GetCustomAttributes(typeof(JsonOptionalPropertyAttribute), false).FirstOrDefault(); - if (optional != null) - continue; - - isDif = true; - log.Write(LogVerbosity.Warning, $"Local object has property `{prop}` but was not found in received object of type `{type.Name}`"); - } - - if (isDif) - log.Write(LogVerbosity.Debug, "Returned data: " + obj); - } - - private PropertyInfo GetProperty(string name, PropertyInfo[] props) - { - foreach (var prop in props) - { - var attr = prop.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault(); - if (attr == null) - { - if (prop.Name.ToLower() == name.ToLower()) - return prop; - } - else - { - if (((JsonPropertyAttribute)attr).PropertyName == name) - return prop; - } - } - return null; - } - - private bool IsSimple(Type type) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) - { - // nullable type, check if the nested type is simple. - return IsSimple(type.GetGenericArguments()[0]); - } - return type.IsPrimitive - || type.IsEnum - || type == typeof(string) - || type == typeof(decimal); - } - - public virtual void Dispose() - { - authProvider?.Credentials?.Dispose(); - log.Write(LogVerbosity.Debug, "Disposing exchange client"); - } } } diff --git a/CryptoExchange.Net/SocketClient.cs b/CryptoExchange.Net/SocketClient.cs new file mode 100644 index 0000000..645b47d --- /dev/null +++ b/CryptoExchange.Net/SocketClient.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Logging; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace CryptoExchange.Net +{ + public abstract class SocketClient: BaseClient + { + #region fields + public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory(); + + private const SslProtocols protocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls; + + protected List sockets = new List(); + + protected TimeSpan reconnectInterval; + protected Func dataInterpreter; + #endregion + + protected SocketClient(SocketClientOptions exchangeOptions, AuthenticationProvider authenticationProvider): base(exchangeOptions, authenticationProvider) + { + Configure(exchangeOptions); + } + + /// + /// Configure the client using the provided options + /// + /// Options + protected void Configure(SocketClientOptions exchangeOptions) + { + reconnectInterval = exchangeOptions.ReconnectInterval; + } + + protected void SetDataInterpreter(Func handler) + { + dataInterpreter = handler; + } + + protected virtual IWebsocket CreateSocket(string address) + { + var socket = SocketFactory.CreateWebsocket(log, address); + log.Write(LogVerbosity.Debug, "Created new socket for " + address); + + if (apiProxy != null) + socket.SetProxy(apiProxy.Host, apiProxy.Port); + + socket.SetEnabledSslProtocols(protocols); + socket.DataInterpreter = dataInterpreter; + socket.OnClose += () => + { + socket.DisconnectTime = DateTime.UtcNow; + SocketOnClose(socket); + }; + socket.OnError += (e) => + { + log.Write(LogVerbosity.Warning, $"Socket {socket.Id} error: " + e.ToString()); + SocketError(socket, e); + }; + socket.OnOpen += () => + { + socket.ShouldReconnect = true; + SocketOpened(socket); + }; + return socket; + } + + protected abstract void SocketOpened(IWebsocket socket); + protected abstract void SocketClosed(IWebsocket socket); + protected abstract void SocketError(IWebsocket socket, Exception ex); + protected abstract bool SocketReconnect(SocketSubscription socket, TimeSpan disconnectedTime); + + protected virtual CallResult ConnectSocket(IWebsocket socket) + { + if (socket.Connect().Result) + { + var subscription = new SocketSubscription(socket); + lock (sockets) + sockets.Add(subscription); + return new CallResult(subscription, null); + } + + socket.Dispose(); + return new CallResult(null, new CantConnectError()); + } + + protected virtual void SocketOnClose(IWebsocket socket) + { + if (socket.ShouldReconnect) + { + log.Write(LogVerbosity.Info, $"Socket {socket.Id} Connection lost, going to try to reconnect"); + Task.Run(() => + { + Thread.Sleep(reconnectInterval); + if (!socket.Connect().Result) + { + log.Write(LogVerbosity.Debug, $"Socket {socket.Id} failed to reconnect"); + return; // Connect() should result in a SocketClosed event so we end up here again + } + + log.Write(LogVerbosity.Info, $"Socket {socket.Id} reconnected after {DateTime.UtcNow - socket.DisconnectTime.Value}"); + + SocketSubscription subscription; + lock(sockets) + subscription = sockets.Single(s => s.Socket == socket); + + SocketReconnected(subscription, DateTime.UtcNow - socket.DisconnectTime.Value); + }); + } + else + { + socket.Dispose(); + lock (sockets) + { + var subscription = sockets.SingleOrDefault(s => s.Socket == socket); + if(subscription != null) + sockets.Remove(subscription); + } + } + } + + protected virtual void Send(IWebsocket socket, T obj, NullValueHandling nullValueHandling = NullValueHandling.Ignore) + { + Send(socket, JsonConvert.SerializeObject(obj, Formatting.None, new JsonSerializerSettings { NullValueHandling = nullValueHandling })); + } + + protected virtual void Send(IWebsocket socket, string data) + { + log.Write(LogVerbosity.Debug, $"Socket {socket.Id} sending data: {data}"); + socket.Send(data); + } + + protected virtual async Task> SendAndWait(IWebsocket socket, T obj, Func waitingFor, int timeout=5000) + { + return await Task.Run(() => + { + var data = JsonConvert.SerializeObject(obj); + ManualResetEvent evnt = new ManualResetEvent(false); + string result = null; + var onMessageAction = new Action((msg) => + { + if (!waitingFor(JToken.Parse(msg))) + return; + + log.Write(LogVerbosity.Debug, "Socket received query response: " + msg); + result = msg; + evnt?.Set(); + }); + + socket.OnMessage += onMessageAction; + Send(socket, data); + evnt.WaitOne(timeout); + socket.OnMessage -= onMessageAction; + evnt.Dispose(); + evnt = null; + if (result == null) + return new CallResult(null, new ServerError("No response from server")); + return new CallResult(result, null); + }).ConfigureAwait(false); + } + + public override void Dispose() + { + lock(sockets) + foreach (var socket in sockets) + socket.Socket.Dispose(); + sockets.Clear(); + } + } +} diff --git a/CryptoExchange.Net/Implementation/BaseSocket.cs b/CryptoExchange.Net/Sockets/BaseSocket.cs similarity index 70% rename from CryptoExchange.Net/Implementation/BaseSocket.cs rename to CryptoExchange.Net/Sockets/BaseSocket.cs index 555c89a..1c755e9 100644 --- a/CryptoExchange.Net/Implementation/BaseSocket.cs +++ b/CryptoExchange.Net/Sockets/BaseSocket.cs @@ -11,22 +11,30 @@ using SuperSocket.ClientEngine; using SuperSocket.ClientEngine.Proxy; using WebSocket4Net; -namespace CryptoExchange.Net.Implementation +namespace CryptoExchange.Net.Sockets { public class BaseSocket: IWebsocket { + internal static int lastStreamId; + private static readonly object streamIdLock = new object(); + protected WebSocket socket; protected Log log; protected object socketLock = new object(); + protected DateTime? lostTime = null; - protected readonly List> errorhandlers = new List>(); - protected readonly List openhandlers = new List(); - protected readonly List closehandlers = new List(); - protected readonly List> messagehandlers = new List>(); + protected readonly List> errorHandlers = new List>(); + protected readonly List openHandlers = new List(); + protected readonly List closeHandlers = new List(); + protected readonly List> messageHandlers = new List>(); + public int Id { get; } + public DateTime? DisconnectTime { get; set; } + public bool ShouldReconnect { get; set; } public string Url { get; } public bool IsClosed => socket.State == WebSocketState.Closed; public bool IsOpen => socket.State == WebSocketState.Open; + public Func DataInterpreter { get; set; } public bool PingConnection { @@ -56,6 +64,7 @@ namespace CryptoExchange.Net.Implementation public BaseSocket(Log log, string url, IDictionary cookies, IDictionary headers) { + Id = NextStreamId(); this.log = log; Url = url; socket = new WebSocket(url, cookies: cookies.ToList(), customHeaderItems: headers.ToList()) @@ -63,33 +72,38 @@ namespace CryptoExchange.Net.Implementation EnableAutoSendPing = true, AutoSendPingInterval = 10 }; - socket.Opened += (o, s) => Handle(openhandlers); - socket.Closed += (o, s) => Handle(closehandlers); - socket.Error += (o, s) => Handle(errorhandlers, s.Exception); - socket.MessageReceived += (o, s) => Handle(messagehandlers, s.Message); - socket.EnableAutoSendPing = true; - socket.AutoSendPingInterval = 10; + socket.Opened += (o, s) => Handle(openHandlers); + socket.Closed += (o, s) => Handle(closeHandlers); + socket.Error += (o, s) => Handle(errorHandlers, s.Exception); + socket.MessageReceived += (o, s) => Handle(messageHandlers, s.Message); + socket.DataReceived += (o, s) => HandleByteData(s.Data); + } + + private void HandleByteData(byte[] data) + { + var message = DataInterpreter(data); + Handle(messageHandlers, message); } public event Action OnClose { - add => closehandlers.Add(value); - remove => closehandlers.Remove(value); + add => closeHandlers.Add(value); + remove => closeHandlers.Remove(value); } public event Action OnMessage { - add => messagehandlers.Add(value); - remove => messagehandlers.Remove(value); + add => messageHandlers.Add(value); + remove => messageHandlers.Remove(value); } public event Action OnError { - add => errorhandlers.Add(value); - remove => errorhandlers.Remove(value); + add => errorHandlers.Add(value); + remove => errorHandlers.Remove(value); } public event Action OnOpen { - add => openhandlers.Add(value); - remove => openhandlers.Remove(value); + add => openHandlers.Add(value); + remove => openHandlers.Remove(value); } protected static void Handle(List handlers) @@ -112,12 +126,12 @@ namespace CryptoExchange.Net.Implementation { if (socket == null || IsClosed) { - log.Write(LogVerbosity.Debug, "Socket was already closed/disposed"); + log.Write(LogVerbosity.Debug, $"Socket {Id} was already closed/disposed"); return; } var waitLock = new object(); - log.Write(LogVerbosity.Debug, "Closing websocket"); + log.Write(LogVerbosity.Debug, $"Socket {Id} closing"); ManualResetEvent evnt = new ManualResetEvent(false); var handler = new EventHandler((o, a) => { @@ -133,7 +147,7 @@ namespace CryptoExchange.Net.Implementation evnt.Dispose(); evnt = null; } - log.Write(LogVerbosity.Debug, "Websocket closed"); + log.Write(LogVerbosity.Debug, $"Socket {Id} closed"); } }).ConfigureAwait(false); } @@ -150,7 +164,7 @@ namespace CryptoExchange.Net.Implementation bool connected; lock (socketLock) { - log.Write(LogVerbosity.Debug, "Connecting websocket"); + log.Write(LogVerbosity.Debug, $"Socket {Id} connecting"); var waitLock = new object(); ManualResetEvent evnt = new ManualResetEvent(false); var handler = new EventHandler((o, a) => @@ -178,9 +192,9 @@ namespace CryptoExchange.Net.Implementation } connected = socket.State == WebSocketState.Open; if (connected) - log.Write(LogVerbosity.Debug, "Websocket connected"); + log.Write(LogVerbosity.Debug, $"Socket {Id} connected"); else - log.Write(LogVerbosity.Debug, "Websocket connection failed, state: " + socket.State); + log.Write(LogVerbosity.Debug, $"Socket {Id} connection failed, state: " + socket.State); } if (socket.State == WebSocketState.Connecting) @@ -208,15 +222,24 @@ namespace CryptoExchange.Net.Implementation lock (socketLock) { if (socket != null) - log.Write(LogVerbosity.Debug, "Disposing websocket"); + log.Write(LogVerbosity.Debug, $"Socket {Id} sisposing websocket"); socket?.Dispose(); socket = null; - errorhandlers.Clear(); - openhandlers.Clear(); - closehandlers.Clear(); - messagehandlers.Clear(); + errorHandlers.Clear(); + openHandlers.Clear(); + closeHandlers.Clear(); + messageHandlers.Clear(); + } + } + + private int NextStreamId() + { + lock (streamIdLock) + { + lastStreamId++; + return lastStreamId; } } } diff --git a/CryptoExchange.Net/Sockets/SocketSubscription.cs b/CryptoExchange.Net/Sockets/SocketSubscription.cs new file mode 100644 index 0000000..16668c4 --- /dev/null +++ b/CryptoExchange.Net/Sockets/SocketSubscription.cs @@ -0,0 +1,39 @@ +using CryptoExchange.Net.Interfaces; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CryptoExchange.Net.Sockets +{ + public class SocketSubscription + { + public event Action ConnectionLost; + public event Action ConnectionRestored; + + public IWebsocket Socket { get; set; } + public object Request { get; set; } + + private bool lostTriggered; + + public SocketSubscription(IWebsocket socket) + { + Socket = socket; + + Socket.OnClose += () => + { + if (lostTriggered) + return; + + lostTriggered = true; + if (Socket.ShouldReconnect) + ConnectionLost?.Invoke(); + }; + Socket.OnOpen += () => + { + lostTriggered = false; + if (Socket.DisconnectTime != null) + ConnectionRestored?.Invoke(); + }; + } + } +} diff --git a/CryptoExchange.Net/Implementation/WebsocketFactory.cs b/CryptoExchange.Net/Sockets/WebsocketFactory.cs similarity index 86% rename from CryptoExchange.Net/Implementation/WebsocketFactory.cs rename to CryptoExchange.Net/Sockets/WebsocketFactory.cs index c06a951..3b27532 100644 --- a/CryptoExchange.Net/Implementation/WebsocketFactory.cs +++ b/CryptoExchange.Net/Sockets/WebsocketFactory.cs @@ -1,7 +1,7 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Logging; -namespace CryptoExchange.Net.Implementation +namespace CryptoExchange.Net.Sockets { public class WebsocketFactory : IWebsocketFactory {