diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs index 3fac0e9..4a2e2f5 100644 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs +++ b/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs @@ -68,11 +68,8 @@ namespace CryptoExchange.Net.UnitTests { } - public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, Dictionary providedParameters, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, RequestBodyFormat bodyFormat, out SortedDictionary uriParameters, out SortedDictionary bodyParameters, out Dictionary headers) + public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, IDictionary uriParams, IDictionary bodyParams, Dictionary headers, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, RequestBodyFormat bodyFormat) { - bodyParameters = new SortedDictionary(); - uriParameters = new SortedDictionary(); - headers = new Dictionary(); } public string GetKey() => _credentials.Key.GetString(); diff --git a/CryptoExchange.Net/Authentication/AuthenticationProvider.cs b/CryptoExchange.Net/Authentication/AuthenticationProvider.cs index 6d2f5e2..4d75476 100644 --- a/CryptoExchange.Net/Authentication/AuthenticationProvider.cs +++ b/CryptoExchange.Net/Authentication/AuthenticationProvider.cs @@ -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,6 +16,8 @@ namespace CryptoExchange.Net.Authentication /// public abstract class AuthenticationProvider : IDisposable { + internal IAuthTimeProvider TimeProvider { get; set; } = new AuthTimeProvider(); + /// /// Provided credentials /// @@ -44,7 +47,6 @@ namespace CryptoExchange.Net.Authentication /// The Api client sending the request /// The uri for the request /// The method of the request - /// The request parameters /// If the requests should be authenticated /// Array serialization type /// The position where the providedParameters should go @@ -56,14 +58,13 @@ namespace CryptoExchange.Net.Authentication RestApiClient apiClient, Uri uri, HttpMethod method, - Dictionary providedParameters, + IDictionary uriParameters, + IDictionary bodyParameters, + Dictionary headers, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, - RequestBodyFormat requestBodyFormat, - out SortedDictionary uriParameters, - out SortedDictionary bodyParameters, - out Dictionary headers + RequestBodyFormat requestBodyFormat ); /// @@ -418,9 +419,9 @@ namespace CryptoExchange.Net.Authentication /// /// /// - 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)!; } /// @@ -428,7 +429,7 @@ namespace CryptoExchange.Net.Authentication /// /// /// - protected static string GetMillisecondTimestamp(RestApiClient apiClient) + protected string GetMillisecondTimestamp(RestApiClient apiClient) { return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value.ToString(CultureInfo.InvariantCulture); } diff --git a/CryptoExchange.Net/Clients/RestApiClient.cs b/CryptoExchange.Net/Clients/RestApiClient.cs index ff5f97f..7ef595b 100644 --- a/CryptoExchange.Net/Clients/RestApiClient.cs +++ b/CryptoExchange.Net/Clients/RestApiClient.cs @@ -40,23 +40,33 @@ namespace CryptoExchange.Net.Clients /// /// Request body content type /// - protected RequestBodyFormat RequestBodyFormat = RequestBodyFormat.Json; + protected internal RequestBodyFormat RequestBodyFormat = RequestBodyFormat.Json; /// /// How to serialize array parameters when making requests /// - protected ArrayParametersSerialization ArraySerialization = ArrayParametersSerialization.Array; + protected internal ArrayParametersSerialization ArraySerialization = ArrayParametersSerialization.Array; /// /// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody) /// - protected string RequestBodyEmptyContent = "{}"; + protected internal string RequestBodyEmptyContent = "{}"; /// /// Request headers to be sent with each request /// protected Dictionary? StandardRequestHeaders { get; set; } + /// + /// Whether parameters need to be ordered + /// + protected internal bool OrderParameters { get; set; } = true; + + /// + /// Parameter order comparer + /// + protected IComparer ParameterOrderComparer { get; } = new OrderedStringComparer(); + /// /// Where to put the parameters for requests with different Http methods /// @@ -269,22 +279,9 @@ namespace CryptoExchange.Net.Clients 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 delegateValue) - parameters[kvp.Key] = delegateValue(); - } - - if (parameterPosition == HttpMethodParameterPosition.InUri) - { - foreach (var parameter in parameters) - uri = uri.AddQueryParmeter(parameter.Key, parameter.Value.ToString()); - } - var headers = new Dictionary(); - var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary(parameters) : new SortedDictionary(); - var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary(parameters) : new SortedDictionary(); + var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? CreateParameterDictionary(parameters) : new Dictionary(); + var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? CreateParameterDictionary(parameters) : new Dictionary(); if (AuthenticationProvider != null) { try @@ -293,14 +290,13 @@ namespace CryptoExchange.Net.Clients this, uri, definition.Method, - parameters, + uriParameters, + bodyParameters, + headers, definition.Authenticated, arraySerialization, parameterPosition, - bodyFormat, - out uriParameters, - out bodyParameters, - out headers); + bodyFormat); } catch (Exception ex) { @@ -346,7 +342,7 @@ namespace CryptoExchange.Net.Clients if (parameterPosition == HttpMethodParameterPosition.InBody) { var contentType = bodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader; - if (bodyParameters.Any()) + if (bodyParameters.Count != 0) WriteParamBody(request, bodyParameters, contentType); else request.SetContent(RequestBodyEmptyContent, contentType); @@ -603,7 +599,7 @@ namespace CryptoExchange.Net.Clients 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]"); + 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(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error); } @@ -726,8 +722,8 @@ namespace CryptoExchange.Net.Clients } var headers = new Dictionary(); - var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? new SortedDictionary(parameters) : new SortedDictionary(); - var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? new SortedDictionary(parameters) : new SortedDictionary(); + var uriParameters = parameterPosition == HttpMethodParameterPosition.InUri ? CreateParameterDictionary(parameters) : new Dictionary(); + var bodyParameters = parameterPosition == HttpMethodParameterPosition.InBody ? CreateParameterDictionary(parameters) : new Dictionary(); if (AuthenticationProvider != null) { try @@ -736,14 +732,13 @@ 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,7 +799,7 @@ namespace CryptoExchange.Net.Clients /// The request to set the parameters on /// The parameters to set /// The content type of the data - protected virtual void WriteParamBody(IRequest request, SortedDictionary parameters, string contentType) + protected virtual void WriteParamBody(IRequest request, IDictionary parameters, string contentType) { if (contentType == Constants.JsonContentHeader) { @@ -859,6 +854,19 @@ namespace CryptoExchange.Net.Clients return new ServerRateLimitError(message); } + /// + /// Create the parameter IDictionary + /// + /// + /// + protected internal IDictionary CreateParameterDictionary(IDictionary parameters) + { + if (!OrderParameters) + return parameters; + + return new SortedDictionary(parameters, ParameterOrderComparer); + } + /// /// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues /// diff --git a/CryptoExchange.Net/Converters/JsonNet/DateTimeConverter.cs b/CryptoExchange.Net/Converters/JsonNet/DateTimeConverter.cs index c5cb7fd..2540d36 100644 --- a/CryptoExchange.Net/Converters/JsonNet/DateTimeConverter.cs +++ b/CryptoExchange.Net/Converters/JsonNet/DateTimeConverter.cs @@ -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 } } + /// + /// Parse a long value to datetime + /// + /// + /// + 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); + } + + /// + /// Parse a string value to datetime + /// + /// + /// + 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); + } + /// /// Convert a seconds since epoch (01-01-1970) value to DateTime /// diff --git a/CryptoExchange.Net/Converters/JsonNet/JsonNetMessageAccessor.cs b/CryptoExchange.Net/Converters/JsonNet/JsonNetMessageAccessor.cs index e19903b..bd54ad7 100644 --- a/CryptoExchange.Net/Converters/JsonNet/JsonNetMessageAccessor.cs +++ b/CryptoExchange.Net/Converters/JsonNet/JsonNetMessageAccessor.cs @@ -224,7 +224,7 @@ namespace CryptoExchange.Net.Converters.JsonNet public override bool OriginalDataAvailable => _stream?.CanSeek == true; /// - public async Task Read(Stream stream, bool bufferStream) + public async Task 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; } /// public override string GetOriginalString() @@ -290,7 +291,7 @@ namespace CryptoExchange.Net.Converters.JsonNet private ReadOnlyMemory _bytes; /// - public bool Read(ReadOnlyMemory data) + public CallResult Read(ReadOnlyMemory 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; } /// diff --git a/CryptoExchange.Net/Converters/SystemTextJson/DateTimeConverter.cs b/CryptoExchange.Net/Converters/SystemTextJson/DateTimeConverter.cs index 3fae00f..da52d66 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/DateTimeConverter.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/DateTimeConverter.cs @@ -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,102 @@ namespace CryptoExchange.Net.Converters.SystemTextJson } } + /// + /// Parse a long value to datetime + /// + /// + /// + 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); + } + + /// + /// Parse a string value to datetime + /// + /// + /// + 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); + } + /// /// Convert a seconds since epoch (01-01-1970) value to DateTime /// diff --git a/CryptoExchange.Net/Converters/SystemTextJson/SystemTextJsonMessageAccessor.cs b/CryptoExchange.Net/Converters/SystemTextJson/SystemTextJsonMessageAccessor.cs index 81c3474..9ab3b10 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/SystemTextJsonMessageAccessor.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/SystemTextJsonMessageAccessor.cs @@ -188,7 +188,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson public override bool OriginalDataAvailable => _stream?.CanSeek == true; /// - public async Task Read(Stream stream, bool bufferStream) + public async Task Read(Stream stream, bool bufferStream) { if (bufferStream && stream is not MemoryStream) { @@ -211,15 +211,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; } + /// public override string GetOriginalString() { @@ -249,7 +250,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private ReadOnlyMemory _bytes; /// - public bool Read(ReadOnlyMemory data) + public CallResult Read(ReadOnlyMemory data) { _bytes = data; @@ -257,14 +258,14 @@ namespace CryptoExchange.Net.Converters.SystemTextJson { _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; } /// diff --git a/CryptoExchange.Net/ExtensionMethods.cs b/CryptoExchange.Net/ExtensionMethods.cs index 125a0e0..a716579 100644 --- a/CryptoExchange.Net/ExtensionMethods.cs +++ b/CryptoExchange.Net/ExtensionMethods.cs @@ -342,7 +342,7 @@ namespace CryptoExchange.Net /// /// /// - public static Uri SetParameters(this Uri baseUri, SortedDictionary parameters, ArrayParametersSerialization arraySerialization) + public static Uri SetParameters(this Uri baseUri, IDictionary parameters, ArrayParametersSerialization arraySerialization) { var uriBuilder = new UriBuilder(); uriBuilder.Scheme = baseUri.Scheme; diff --git a/CryptoExchange.Net/Interfaces/IAuthTimeProvider.cs b/CryptoExchange.Net/Interfaces/IAuthTimeProvider.cs new file mode 100644 index 0000000..07357fe --- /dev/null +++ b/CryptoExchange.Net/Interfaces/IAuthTimeProvider.cs @@ -0,0 +1,16 @@ +using System; + +namespace CryptoExchange.Net.Interfaces +{ + /// + /// Time provider + /// + internal interface IAuthTimeProvider + { + /// + /// Get current time + /// + /// + DateTime GetTime(); + } +} diff --git a/CryptoExchange.Net/Interfaces/IMessageAccessor.cs b/CryptoExchange.Net/Interfaces/IMessageAccessor.cs index 7916c18..f65d066 100644 --- a/CryptoExchange.Net/Interfaces/IMessageAccessor.cs +++ b/CryptoExchange.Net/Interfaces/IMessageAccessor.cs @@ -84,7 +84,7 @@ namespace CryptoExchange.Net.Interfaces /// /// /// - Task Read(Stream stream, bool bufferStream); + Task Read(Stream stream, bool bufferStream); } /// @@ -96,6 +96,6 @@ namespace CryptoExchange.Net.Interfaces /// Load a data message /// /// - bool Read(ReadOnlyMemory data); + CallResult Read(ReadOnlyMemory data); } } diff --git a/CryptoExchange.Net/Objects/AuthTimeProvider.cs b/CryptoExchange.Net/Objects/AuthTimeProvider.cs new file mode 100644 index 0000000..9cdeb96 --- /dev/null +++ b/CryptoExchange.Net/Objects/AuthTimeProvider.cs @@ -0,0 +1,10 @@ +using CryptoExchange.Net.Interfaces; +using System; + +namespace CryptoExchange.Net.Objects +{ + internal class AuthTimeProvider : IAuthTimeProvider + { + public DateTime GetTime() => DateTime.UtcNow; + } +} diff --git a/CryptoExchange.Net/Objects/OrderedStringComparer.cs b/CryptoExchange.Net/Objects/OrderedStringComparer.cs new file mode 100644 index 0000000..15e9b2d --- /dev/null +++ b/CryptoExchange.Net/Objects/OrderedStringComparer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace CryptoExchange.Net.Objects +{ + /// + /// Order string comparer, sorts by alphabetical order + /// + public class OrderedStringComparer : IComparer + { + /// + /// Compare function + /// + /// + /// + /// + 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); + } + } +} diff --git a/CryptoExchange.Net/Testing/Comparers/JsonNetComparer.cs b/CryptoExchange.Net/Testing/Comparers/JsonNetComparer.cs new file mode 100644 index 0000000..3a4957a --- /dev/null +++ b/CryptoExchange.Net/Testing/Comparers/JsonNetComparer.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Reflection; +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? 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) + jsonObject = jsonObject[nestedJsonProperty]; + + 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; + var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast().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 = resultData.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast().SingleOrDefault())); + int i = 0; + foreach (var item in jObjs.Values()) + { + var arrayProp = resultProps.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? 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(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? 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().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.Values()) + { + var arrayProp = resultProps.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 + { + 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() != dec) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {dec}"); + } + else if (objectValue is DateTime time) + { + if (time != DateTimeConverter.ParseFromString(jsonValue.Value()!)) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {time}"); + } + else if (propertyType.IsEnum) + { + // TODO enum comparing + } + else if (!jsonValue.Value()!.Equals(Convert.ToString(objectValue, CultureInfo.InvariantCulture), StringComparison.InvariantCultureIgnoreCase)) + { + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {objectValue}"); + } + } + else if (jsonValue.Type == JTokenType.Integer) + { + if (objectValue is DateTime time) + { + if (time != DateTimeConverter.ParseFromLong(jsonValue.Value()!)) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {time}"); + } + else if (propertyType.IsEnum) + { + // TODO enum comparing + } + else if (jsonValue.Value() != Convert.ToInt64(objectValue)) + { + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {Convert.ToInt64(objectValue)}"); + } + } + else if (jsonValue.Type == JTokenType.Boolean) + { + if (jsonValue.Value() != (bool)objectValue) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {(bool)objectValue}"); + } + } + } +} diff --git a/CryptoExchange.Net/Testing/Comparers/SystemTextJsonComparer.cs b/CryptoExchange.Net/Testing/Comparers/SystemTextJsonComparer.cs new file mode 100644 index 0000000..a15f6e6 --- /dev/null +++ b/CryptoExchange.Net/Testing/Comparers/SystemTextJsonComparer.cs @@ -0,0 +1,289 @@ +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? 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) + jsonObject = jsonObject[nestedJsonProperty]; + + 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; + var list = (IEnumerable)resultData; + 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; + var resultProps = resultObj.GetType().GetProperties().Select(p => (p, p.GetCustomAttributes(typeof(ArrayPropertyAttribute), true).Cast().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 + { + 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? 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? 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().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.Values()) + { + var arrayProp = resultProps.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 + { + 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() != dec) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {dec}"); + } + else if (objectValue is DateTime time) + { + if (time != DateTimeConverter.ParseFromString(jsonValue.Value()!)) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {time}"); + } + else if (propertyType.IsEnum) + { + // TODO enum comparing + } + else if (!jsonValue.Value()!.Equals(Convert.ToString(objectValue, CultureInfo.InvariantCulture), StringComparison.InvariantCultureIgnoreCase)) + { + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {objectValue}"); + } + } + else if (jsonValue.Type == JTokenType.Integer) + { + if (objectValue is DateTime time) + { + if (time != DateTimeConverter.ParseFromDouble(jsonValue.Value()!)) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {time}"); + } + else if (jsonValue.Value() != Convert.ToInt64(objectValue)) + { + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {Convert.ToInt64(objectValue)}"); + } + } + else if (jsonValue.Type == JTokenType.Boolean) + { + if (jsonValue.Value() != (bool)objectValue) + throw new Exception($"{method}: {property} not equal: {jsonValue.Value()} vs {(bool)objectValue}"); + } + } + } +} diff --git a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs new file mode 100644 index 0000000..d36de76 --- /dev/null +++ b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs @@ -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); + } + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestAuthTimeProvider.cs b/CryptoExchange.Net/Testing/Implementations/TestAuthTimeProvider.cs new file mode 100644 index 0000000..ac6fdfa --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestAuthTimeProvider.cs @@ -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; + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestNonceProvider.cs b/CryptoExchange.Net/Testing/Implementations/TestNonceProvider.cs new file mode 100644 index 0000000..5f97248 --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestNonceProvider.cs @@ -0,0 +1,23 @@ +using CryptoExchange.Net.Interfaces; + +namespace CryptoExchange.Net.Testing.Implementations +{ + /// + /// Test implementation for nonce provider, returning a prespecified nonce + /// + public class TestNonceProvider : INonceProvider + { + private readonly long _nonce; + + /// + /// ctor + /// + public TestNonceProvider(long nonce) + { + _nonce = nonce; + } + + /// + public long GetNonce() => _nonce; + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestRequest.cs b/CryptoExchange.Net/Testing/Implementations/TestRequest.cs new file mode 100644 index 0000000..91a7114 --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestRequest.cs @@ -0,0 +1,50 @@ +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 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) + { + } + + public Dictionary> GetHeaders() => new(); + + public Task GetResponseAsync(CancellationToken cancellationToken) => Task.FromResult(_response); + + public void SetContent(byte[] data) + { + Content = Encoding.UTF8.GetString(data); + } + + public void SetContent(string data, string contentType) + { + Content = data; + } + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestRequestFactory.cs b/CryptoExchange.Net/Testing/Implementations/TestRequestFactory.cs new file mode 100644 index 0000000..6bcd8c1 --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestRequestFactory.cs @@ -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; + } + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestResponse.cs b/CryptoExchange.Net/Testing/Implementations/TestResponse.cs new file mode 100644 index 0000000..3cb32a0 --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestResponse.cs @@ -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>> ResponseHeaders { get; } = new Dictionary>(); + + public TestResponse(HttpStatusCode code, Stream response) + { + StatusCode = code; + IsSuccessStatusCode = code == HttpStatusCode.OK; + _response = response; + } + + public void Close() + { + } + + public Task GetResponseStreamAsync() => Task.FromResult(_response); + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestSocket.cs b/CryptoExchange.Net/Testing/Implementations/TestSocket.cs new file mode 100644 index 0000000..06195b1 --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestSocket.cs @@ -0,0 +1,81 @@ +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? OnMessageSend; + + public bool CanConnect { get; set; } = true; + public bool Connected { get; set; } + + public event Func? OnClose; +#pragma warning disable 0067 + public event Func? OnReconnected; + public event Func? OnReconnecting; + public event Func? OnRequestRateLimited; + public event Func? OnError; +#pragma warning restore 0067 + public event Func? OnRequestSent; + public event Action>? OnStreamMessage; + public event Func? OnOpen; + + public int Id { get; } + public bool IsClosed => !Connected; + public bool IsOpen => Connected; + public double IncomingKbps => 0; + public Uri Uri => new("wss://test.com/ws"); + public Func>? GetReconnectionUrl { get; set; } + + public Task ConnectAsync() + { + Connected = CanConnect; + 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); + OnMessageSend?.Invoke(data); + } + + 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(Encoding.UTF8.GetBytes(data))); + } + + public void InvokeMessage(T data) + { + OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(data)))); + } + + public Task ReconnectAsync() => throw new NotImplementedException(); + public void Dispose() { } + } +} diff --git a/CryptoExchange.Net/Testing/Implementations/TestWebsocketFactory.cs b/CryptoExchange.Net/Testing/Implementations/TestWebsocketFactory.cs new file mode 100644 index 0000000..3fac7e9 --- /dev/null +++ b/CryptoExchange.Net/Testing/Implementations/TestWebsocketFactory.cs @@ -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; + } +} diff --git a/CryptoExchange.Net/Testing/RestRequestValidator.cs b/CryptoExchange.Net/Testing/RestRequestValidator.cs new file mode 100644 index 0000000..6865fb9 --- /dev/null +++ b/CryptoExchange.Net/Testing/RestRequestValidator.cs @@ -0,0 +1,190 @@ +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 +{ + /// + /// Validator for REST requests, comparing path, http method, authentication and response parsing + /// + /// The Rest client + public class RestRequestValidator where TClient : BaseRestClient + { + private readonly TClient _client; + private readonly Func _isAuthenticated; + private readonly string _folder; + private readonly string _baseAddress; + private readonly string? _nestedPropertyForCompare; + private readonly bool _stjCompare; + + /// + /// ctor + /// + /// Client to test + /// Folder for json test values + /// The base address that is expected + /// Func for checking if the request is authenticated + /// Property to use for compare + /// Use System.Text.Json for comparing + public RestRequestValidator(TClient client, string folder, string baseAddress, Func isAuthenticated, string? nestedPropertyForCompare = null, bool stjCompare = true) + { + _client = client; + _folder = folder; + _baseAddress = baseAddress; + _nestedPropertyForCompare = nestedPropertyForCompare; + _isAuthenticated = isAuthenticated; + _stjCompare = stjCompare; + } + + /// + /// Validate a request + /// + /// Expected response type + /// Method invocation + /// Method name for looking up json test values + /// Use nested json property for compare + /// Ignore certain properties + /// Use the first item of an json array response + /// Whether to skip the response model validation + /// + /// + public Task ValidateAsync( + Func>> methodInvoke, + string name, + string? nestedJsonProperty = null, + List? ignoreProperties = null, + bool useSingleArrayItem = false, + bool skipResponseValidation = false) + => ValidateAsync(methodInvoke, name, nestedJsonProperty, ignoreProperties, useSingleArrayItem, skipResponseValidation); + + /// + /// Validate a request + /// + /// Expected response type + /// The concrete response type + /// Method invocation + /// Method name for looking up json test values + /// Use nested json property for compare + /// Ignore certain properties + /// Use the first item of an json array response + /// Whether to skip the response model validation + /// + /// + public async Task ValidateAsync( + Func>> methodInvoke, + string name, + string? nestedJsonProperty = null, + List? 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); + } + + /// + /// Validate a request + /// + /// Method invocation + /// Method name for looking up json test values + /// + /// + public async Task ValidateAsync( + Func> 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()!); + + TestHelpers.ConfigureRestClient(_client, "", 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); + } + } +} diff --git a/CryptoExchange.Net/Testing/SocketSubscriptionValidator.cs b/CryptoExchange.Net/Testing/SocketSubscriptionValidator.cs new file mode 100644 index 0000000..7b1d7be --- /dev/null +++ b/CryptoExchange.Net/Testing/SocketSubscriptionValidator.cs @@ -0,0 +1,163 @@ +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 +{ + /// + /// Validator for websocket subscriptions, checking expected requests and responses and comparing update models + /// + /// + public class SocketSubscriptionValidator where TClient : BaseSocketClient + { + private readonly TClient _client; + private readonly string _folder; + private readonly string _baseAddress; + private readonly string? _nestedPropertyForCompare; + private readonly bool _stjCompare; + + /// + /// ctor + /// + /// Client to test + /// Folder for json test values + /// The base address that is expected + /// Property to use for compare + /// Use System.Text.Json for comparing + public SocketSubscriptionValidator(TClient client, string folder, string baseAddress, string? nestedPropertyForCompare = null, bool stjCompare = true) + { + _client = client; + _folder = folder; + _baseAddress = baseAddress; + _nestedPropertyForCompare = nestedPropertyForCompare; + _stjCompare = stjCompare; + } + + /// + /// Validate a subscription + /// + /// The expected update type + /// Subscription method invocation + /// Method name for looking up json test values + /// Use nested json property for compare + /// Ignore certain properties + /// + /// + public async Task ValidateAsync( + Func>, Task>> methodInvoke, + string name, + string? nestedJsonProperty = null, + List? ignoreProperties = 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); + + 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(); + } + else if (lastMessageJson[prop.Name]?.Value() != val.ToString() && ignoreProperties?.Contains(prop.Name) != true) + throw new Exception($"{name} Expected {prop.Name} to be {val}, but was {lastMessageJson[prop.Name]?.Value()}"); + } + + // 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); + else + JsonNetComparer.CompareData(name, update, compareData, nestedJsonProperty ?? _nestedPropertyForCompare, ignoreProperties); + } + } + + await _client.UnsubscribeAllAsync().ConfigureAwait(false); + Trace.Listeners.Remove(listener); + } + } +} diff --git a/CryptoExchange.Net/Testing/TestHelpers.cs b/CryptoExchange.Net/Testing/TestHelpers.cs new file mode 100644 index 0000000..5360218 --- /dev/null +++ b/CryptoExchange.Net/Testing/TestHelpers.cs @@ -0,0 +1,190 @@ +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 +{ + /// + /// Testing helpers + /// + public class TestHelpers + { + [ExcludeFromCodeCoverage] + internal static bool AreEqual(T? self, T? to, params string[] ignore) where T : class + { + if (self != null && to != null) + { + var type = self.GetType(); + var ignoreList = new List(ignore); + foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (ignoreList.Contains(pi.Name)) + continue; + + var selfValue = type.GetProperty(pi.Name)!.GetValue(self, null); + var toValue = type.GetProperty(pi.Name)!.GetValue(to, null); + + if (pi.PropertyType.IsClass && !pi.PropertyType.Module.ScopeName.Equals("System.Private.CoreLib.dll")) + { + // Check of "CommonLanguageRuntimeLibrary" is needed because string is also a class + if (AreEqual(selfValue, toValue, ignore)) + continue; + + return false; + } + + if (selfValue != toValue && (selfValue == null || !selfValue.Equals(toValue))) + return false; + } + + return true; + } + + return self == to; + } + + internal static TestSocket ConfigureSocketClient(T client) where T : BaseSocketClient + { + var socket = new TestSocket(); + foreach (var apiClient in client.ApiClients.OfType()) + { + apiClient.SocketFactory = new TestWebsocketFactory(socket); + } + return socket; + } + + internal static void ConfigureRestClient(T client, string data, HttpStatusCode code) where T : BaseRestClient + { + foreach (var apiClient in client.ApiClients.OfType()) + { + 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; + } + } + + /// + /// Check a signature matches the expected signature + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static void CheckSignature( + RestApiClient client, + AuthenticationProvider authProvider, + HttpMethod method, + string path, + Func?, IDictionary?, IDictionary?, string> getSignature, + string expectedSignature, + Dictionary? parameters = null, + DateTime? time = null, + bool disableOrdering = false, + bool compareCase = true, + string host = "https://test.test-api.com") + { + parameters ??= new Dictionary + { + { "test", 123 }, + { "test2", "abc" } + }; + + if (disableOrdering) + client.OrderParameters = false; + + var uriParams = client.ParameterPositions[method] == HttpMethodParameterPosition.InUri ? client.CreateParameterDictionary(parameters) : new Dictionary(); + var bodyParams = client.ParameterPositions[method] == HttpMethodParameterPosition.InBody ? client.CreateParameterDictionary(parameters) : new Dictionary(); + + var headers = new Dictionary(); + + 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}"); + } + + /// + /// Scan the TClient rest client type for missing interface methods + /// + /// + /// + public static void CheckForMissingRestInterfaces() + { + CheckForMissingInterfaces(typeof(TClient), typeof(Task)); + } + + /// + /// Scan the TClient socket client type for missing interface methods + /// + /// + /// + public static void CheckForMissingSocketInterfaces() + { + CheckForMissingInterfaces(typeof(TClient), typeof(Task>)); + } + + 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"); + } + } + } +}