diff --git a/CryptoExchange.Net/Clients/BaseApiClient.cs b/CryptoExchange.Net/Clients/BaseApiClient.cs index b2b5418..57e6f17 100644 --- a/CryptoExchange.Net/Clients/BaseApiClient.cs +++ b/CryptoExchange.Net/Clients/BaseApiClient.cs @@ -1,10 +1,16 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Net.Http; +using System.Text; +using System.Threading.Tasks; using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Logging; using CryptoExchange.Net.Objects; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace CryptoExchange.Net { @@ -15,10 +21,18 @@ namespace CryptoExchange.Net { private ApiCredentials? _apiCredentials; private AuthenticationProvider? _authenticationProvider; - protected Log _log; - protected bool _disposing; private bool _created; + /// + /// Logger + /// + protected Log _log; + + /// + /// If we are disposing + /// + protected bool _disposing; + /// /// The authentication provider for this API client. (null if no credentials are set) /// @@ -77,6 +91,24 @@ namespace CryptoExchange.Net /// public ApiClientOptions Options { get; } + /// + /// The last used id, use NextId() to get the next id and up this + /// + protected static int lastId; + /// + /// Lock for id generating + /// + protected static object idLock = new (); + + /// + /// A default serializer + /// + private static readonly JsonSerializer _defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings + { + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + Culture = CultureInfo.InvariantCulture + }); + /// /// ctor /// @@ -105,6 +137,212 @@ namespace CryptoExchange.Net _authenticationProvider = null; } + /// + /// Tries to parse the json data and return a JToken, validating the input not being empty and being valid json + /// + /// The data to parse + /// + protected CallResult ValidateJson(string data) + { + if (string.IsNullOrEmpty(data)) + { + var info = "Empty data object received"; + _log.Write(LogLevel.Error, info); + return new CallResult(new DeserializeError(info, data)); + } + + try + { + return new CallResult(JToken.Parse(data)); + } + catch (JsonReaderException jre) + { + var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}"; + return new CallResult(new DeserializeError(info, data)); + } + catch (JsonSerializationException jse) + { + var info = $"Deserialize JsonSerializationException: {jse.Message}"; + return new CallResult(new DeserializeError(info, data)); + } + catch (Exception ex) + { + var exceptionInfo = ex.ToLogString(); + var info = $"Deserialize Unknown Exception: {exceptionInfo}"; + return new CallResult(new DeserializeError(info, data)); + } + } + + /// + /// Deserialize a string into an object + /// + /// The type to deserialize into + /// The data to deserialize + /// A specific serializer to use + /// Id of the request the data is returned from (used for grouping logging by request) + /// + protected CallResult Deserialize(string data, JsonSerializer? serializer = null, int? requestId = null) + { + var tokenResult = ValidateJson(data); + if (!tokenResult) + { + _log.Write(LogLevel.Error, tokenResult.Error!.Message); + return new CallResult(tokenResult.Error); + } + + return Deserialize(tokenResult.Data, serializer, requestId); + } + + /// + /// Deserialize a JToken into an object + /// + /// The type to deserialize into + /// The data to deserialize + /// A specific serializer to use + /// Id of the request the data is returned from (used for grouping logging by request) + /// + protected CallResult Deserialize(JToken obj, JsonSerializer? serializer = null, int? requestId = null) + { + serializer ??= _defaultSerializer; + + try + { + return new CallResult(obj.ToObject(serializer)!); + } + catch (JsonReaderException jre) + { + var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message} Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {obj}"; + _log.Write(LogLevel.Error, info); + return new CallResult(new DeserializeError(info, obj)); + } + catch (JsonSerializationException jse) + { + var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message} data: {obj}"; + _log.Write(LogLevel.Error, info); + return new CallResult(new DeserializeError(info, obj)); + } + catch (Exception ex) + { + var exceptionInfo = ex.ToLogString(); + var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {obj}"; + _log.Write(LogLevel.Error, info); + return new CallResult(new DeserializeError(info, obj)); + } + } + + /// + /// Deserialize a stream into an object + /// + /// The type to deserialize into + /// The stream to deserialize + /// A specific serializer to use + /// Id of the request the data is returned from (used for grouping logging by request) + /// Milliseconds response time for the request this stream is a response for + /// + protected async Task> DeserializeAsync(Stream stream, JsonSerializer? serializer = null, int? requestId = null, long? elapsedMilliseconds = null) + { + serializer ??= _defaultSerializer; + string? data = null; + + try + { + // Let the reader keep the stream open so we're able to seek if needed. The calling method will close the stream. + using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true); + + // If we have to output the original json data or output the data into the logging we'll have to read to full response + // in order to log/return the json data + if (Options.OutputOriginalData == true || _log.Level == LogLevel.Trace) + { + data = await reader.ReadToEndAsync().ConfigureAwait(false); + _log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] " : "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms{(_log.Level == LogLevel.Trace ? (": " + data) : "")}"); + var result = Deserialize(data, serializer, requestId); + if (Options.OutputOriginalData == true) + result.OriginalData = data; + return result; + } + + // If we don't have to keep track of the original json data we can use the JsonTextReader to deserialize the stream directly + // into the desired object, which has increased performance over first reading the string value into memory and deserializing from that + using var jsonReader = new JsonTextReader(reader); + _log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] " : "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms"); + return new CallResult(serializer.Deserialize(jsonReader)!); + } + catch (JsonReaderException jre) + { + if (data == null) + { + if (stream.CanSeek) + { + // If we can seek the stream rewind it so we can retrieve the original data that was sent + stream.Seek(0, SeekOrigin.Begin); + data = await ReadStreamAsync(stream).ConfigureAwait(false); + } + else + { + data = "[Data only available in Trace LogLevel]"; + } + } + _log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {data}"); + return new CallResult(new DeserializeError($"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}", data)); + } + catch (JsonSerializationException jse) + { + if (data == null) + { + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + data = await ReadStreamAsync(stream).ConfigureAwait(false); + } + else + { + data = "[Data only available in Trace LogLevel]"; + } + } + + _log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message}, data: {data}"); + return new CallResult(new DeserializeError($"Deserialize JsonSerializationException: {jse.Message}", data)); + } + catch (Exception ex) + { + if (data == null) + { + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + data = await ReadStreamAsync(stream).ConfigureAwait(false); + } + else + { + data = "[Data only available in Trace LogLevel]"; + } + } + + var exceptionInfo = ex.ToLogString(); + _log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {data}"); + return new CallResult(new DeserializeError($"Deserialize Unknown Exception: {exceptionInfo}", data)); + } + } + + private static async Task ReadStreamAsync(Stream stream) + { + using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true); + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + + /// + /// Generate a new unique id. The id is staticly stored so it is guarenteed to be unique across different client instances + /// + /// + protected static int NextId() + { + lock (idLock) + { + lastId += 1; + return lastId; + } + } + /// /// Dispose /// diff --git a/CryptoExchange.Net/Clients/BaseClient.cs b/CryptoExchange.Net/Clients/BaseClient.cs index e03e986..ec26ae0 100644 --- a/CryptoExchange.Net/Clients/BaseClient.cs +++ b/CryptoExchange.Net/Clients/BaseClient.cs @@ -2,14 +2,8 @@ using CryptoExchange.Net.Logging; using CryptoExchange.Net.Objects; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text; -using System.Threading.Tasks; namespace CryptoExchange.Net { @@ -30,24 +24,7 @@ namespace CryptoExchange.Net /// The log object /// protected internal Log log; - /// - /// The last used id, use NextId() to get the next id and up this - /// - protected static int lastId; - /// - /// Lock for id generating - /// - protected static object idLock = new object(); - - /// - /// A default serializer - /// - private static readonly JsonSerializer defaultSerializer = JsonSerializer.Create(new JsonSerializerSettings - { - DateTimeZoneHandling = DateTimeZoneHandling.Utc, - Culture = CultureInfo.InvariantCulture - }); - + /// /// Provided client options /// @@ -72,6 +49,16 @@ namespace CryptoExchange.Net log.Write(LogLevel.Trace, $"Client configuration: {options}, CryptoExchange.Net: v{typeof(BaseClient).Assembly.GetName().Version}, {name}.Net: v{GetType().Assembly.GetName().Version}"); } + /// + /// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options. + /// + /// The credentials to set + public void SetApiCredentials(ApiCredentials credentials) + { + foreach (var apiClient in ApiClients) + apiClient.SetApiCredentials(credentials); + } + /// /// Register an API client /// @@ -83,206 +70,6 @@ namespace CryptoExchange.Net return apiClient; } - /// - /// Tries to parse the json data and return a JToken, validating the input not being empty and being valid json - /// - /// The data to parse - /// - protected CallResult ValidateJson(string data) - { - if (string.IsNullOrEmpty(data)) - { - var info = "Empty data object received"; - log.Write(LogLevel.Error, info); - return new CallResult(new DeserializeError(info, data)); - } - - try - { - return new CallResult(JToken.Parse(data)); - } - catch (JsonReaderException jre) - { - var info = $"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}"; - return new CallResult(new DeserializeError(info, data)); - } - catch (JsonSerializationException jse) - { - var info = $"Deserialize JsonSerializationException: {jse.Message}"; - return new CallResult(new DeserializeError(info, data)); - } - catch (Exception ex) - { - var exceptionInfo = ex.ToLogString(); - var info = $"Deserialize Unknown Exception: {exceptionInfo}"; - return new CallResult(new DeserializeError(info, data)); - } - } - - /// - /// Deserialize a string into an object - /// - /// The type to deserialize into - /// The data to deserialize - /// A specific serializer to use - /// Id of the request the data is returned from (used for grouping logging by request) - /// - protected CallResult Deserialize(string data, JsonSerializer? serializer = null, int? requestId = null) - { - var tokenResult = ValidateJson(data); - if (!tokenResult) - { - log.Write(LogLevel.Error, tokenResult.Error!.Message); - return new CallResult( tokenResult.Error); - } - - return Deserialize(tokenResult.Data, serializer, requestId); - } - - /// - /// Deserialize a JToken into an object - /// - /// The type to deserialize into - /// The data to deserialize - /// A specific serializer to use - /// Id of the request the data is returned from (used for grouping logging by request) - /// - protected CallResult Deserialize(JToken obj, JsonSerializer? serializer = null, int? requestId = null) - { - serializer ??= defaultSerializer; - - try - { - return new CallResult(obj.ToObject(serializer)!); - } - catch (JsonReaderException jre) - { - var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message} Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {obj}"; - log.Write(LogLevel.Error, info); - return new CallResult(new DeserializeError(info, obj)); - } - catch (JsonSerializationException jse) - { - var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message} data: {obj}"; - log.Write(LogLevel.Error, info); - return new CallResult(new DeserializeError(info, obj)); - } - catch (Exception ex) - { - var exceptionInfo = ex.ToLogString(); - var info = $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {obj}"; - log.Write(LogLevel.Error, info); - return new CallResult(new DeserializeError(info, obj)); - } - } - - /// - /// Deserialize a stream into an object - /// - /// The type to deserialize into - /// The stream to deserialize - /// A specific serializer to use - /// Id of the request the data is returned from (used for grouping logging by request) - /// Milliseconds response time for the request this stream is a response for - /// - protected async Task> DeserializeAsync(Stream stream, JsonSerializer? serializer = null, int? requestId = null, long? elapsedMilliseconds = null) - { - serializer ??= defaultSerializer; - string? data = null; - - try - { - // Let the reader keep the stream open so we're able to seek if needed. The calling method will close the stream. - using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true); - - // If we have to output the original json data or output the data into the logging we'll have to read to full response - // in order to log/return the json data - if (ClientOptions.OutputOriginalData || log.Level == LogLevel.Trace) - { - data = await reader.ReadToEndAsync().ConfigureAwait(false); - log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] ": "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms{(log.Level == LogLevel.Trace ? (": " + data) : "")}"); - var result = Deserialize(data, serializer, requestId); - if(ClientOptions.OutputOriginalData) - result.OriginalData = data; - return result; - } - - // If we don't have to keep track of the original json data we can use the JsonTextReader to deserialize the stream directly - // into the desired object, which has increased performance over first reading the string value into memory and deserializing from that - using var jsonReader = new JsonTextReader(reader); - log.Write(LogLevel.Debug, $"{(requestId != null ? $"[{requestId}] ": "")}Response received{(elapsedMilliseconds != null ? $" in {elapsedMilliseconds}" : " ")}ms"); - return new CallResult(serializer.Deserialize(jsonReader)!); - } - catch (JsonReaderException jre) - { - if (data == null) - { - if (stream.CanSeek) - { - // If we can seek the stream rewind it so we can retrieve the original data that was sent - stream.Seek(0, SeekOrigin.Begin); - data = await ReadStreamAsync(stream).ConfigureAwait(false); - } - else - data = "[Data only available in Trace LogLevel]"; - } - log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}, data: {data}"); - return new CallResult(new DeserializeError($"Deserialize JsonReaderException: {jre.Message}, Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}", data)); - } - catch (JsonSerializationException jse) - { - if (data == null) - { - if (stream.CanSeek) - { - stream.Seek(0, SeekOrigin.Begin); - data = await ReadStreamAsync(stream).ConfigureAwait(false); - } - else - data = "[Data only available in Trace LogLevel]"; - } - - log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize JsonSerializationException: {jse.Message}, data: {data}"); - return new CallResult(new DeserializeError($"Deserialize JsonSerializationException: {jse.Message}", data)); - } - catch (Exception ex) - { - if (data == null) - { - if (stream.CanSeek) - { - stream.Seek(0, SeekOrigin.Begin); - data = await ReadStreamAsync(stream).ConfigureAwait(false); - } - else - data = "[Data only available in Trace LogLevel]"; - } - - var exceptionInfo = ex.ToLogString(); - log.Write(LogLevel.Error, $"{(requestId != null ? $"[{requestId}] " : "")}Deserialize Unknown Exception: {exceptionInfo}, data: {data}"); - return new CallResult(new DeserializeError($"Deserialize Unknown Exception: {exceptionInfo}", data)); - } - } - - private static async Task ReadStreamAsync(Stream stream) - { - using var reader = new StreamReader(stream, Encoding.UTF8, false, 512, true); - return await reader.ReadToEndAsync().ConfigureAwait(false); - } - - /// - /// Generate a new unique id. The id is staticly stored so it is guarenteed to be unique across different client instances - /// - /// - protected static int NextId() - { - lock (idLock) - { - lastId += 1; - return lastId; - } - } - /// /// Handle a change in the client options log config /// diff --git a/CryptoExchange.Net/Clients/BaseRestClient.cs b/CryptoExchange.Net/Clients/BaseRestClient.cs index c8a7629..da592c6 100644 --- a/CryptoExchange.Net/Clients/BaseRestClient.cs +++ b/CryptoExchange.Net/Clients/BaseRestClient.cs @@ -1,19 +1,8 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Requests; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace CryptoExchange.Net { @@ -22,475 +11,18 @@ namespace CryptoExchange.Net /// public abstract class BaseRestClient : BaseClient, IRestClient { - /// - /// The factory for creating requests. Used for unit testing - /// - public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); - /// public int TotalRequestsMade => ApiClients.OfType().Sum(s => s.TotalRequestsMade); - /// - /// Request headers to be sent with each request - /// - protected Dictionary? StandardRequestHeaders { get; set; } - - /// - /// Client options - /// - public new BaseRestClientOptions ClientOptions { get; } - /// /// ctor /// /// The name of the API this client is for /// The options for this client - protected BaseRestClient(string name, BaseRestClientOptions options) : base(name, options) + protected BaseRestClient(string name, ClientOptions options) : base(name, options) { if (options == null) throw new ArgumentNullException(nameof(options)); - - ClientOptions = options; - RequestFactory.Configure(options.RequestTimeout, options.Proxy, options.HttpClient); - } - - /// - public void SetApiCredentials(ApiCredentials credentials) - { - foreach (var apiClient in ApiClients) - apiClient.SetApiCredentials(credentials); - } - - /// - /// Execute a request to the uri and returns if it was successful - /// - /// The API client the request is for - /// The uri to send the request to - /// The method of the request - /// Cancellation token - /// The parameters of the request - /// Whether or not the request should be authenticated - /// Where the parameters should be placed, overwrites the value set in the client - /// How array parameters should be serialized, overwrites the value set in the client - /// Credits used for the request - /// The JsonSerializer to use for deserialization - /// Additional headers to send with the request - /// Ignore rate limits for this request - /// - [return: NotNull] - protected virtual async Task SendRequestAsync(RestApiClient apiClient, - Uri uri, - HttpMethod method, - CancellationToken cancellationToken, - Dictionary? parameters = null, - bool signed = false, - HttpMethodParameterPosition? parameterPosition = null, - ArrayParametersSerialization? arraySerialization = null, - int requestWeight = 1, - JsonSerializer? deserializer = null, - Dictionary? additionalHeaders = null, - bool ignoreRatelimit = false) - { - var request = await PrepareRequestAsync(apiClient, uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false); - if (!request) - return new WebCallResult(request.Error!); - - var result = await GetResponseAsync(apiClient, request.Data, deserializer, cancellationToken, true).ConfigureAwait(false); - return result.AsDataless(); - } - - /// - /// Execute a request to the uri and deserialize the response into the provided type parameter - /// - /// The type to deserialize into - /// The API client the request is for - /// The uri to send the request to - /// The method of the request - /// Cancellation token - /// The parameters of the request - /// Whether or not the request should be authenticated - /// Where the parameters should be placed, overwrites the value set in the client - /// How array parameters should be serialized, overwrites the value set in the client - /// Credits used for the request - /// The JsonSerializer to use for deserialization - /// Additional headers to send with the request - /// Ignore rate limits for this request - /// - [return: NotNull] - protected virtual async Task> SendRequestAsync( - RestApiClient apiClient, - Uri uri, - HttpMethod method, - CancellationToken cancellationToken, - Dictionary? parameters = null, - bool signed = false, - HttpMethodParameterPosition? parameterPosition = null, - ArrayParametersSerialization? arraySerialization = null, - int requestWeight = 1, - JsonSerializer? deserializer = null, - Dictionary? additionalHeaders = null, - bool ignoreRatelimit = false - ) where T : class - { - var request = await PrepareRequestAsync(apiClient, uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false); - if (!request) - return new WebCallResult(request.Error!); - - return await GetResponseAsync(apiClient, request.Data, deserializer, cancellationToken, false).ConfigureAwait(false); - } - - /// - /// Prepares a request to be sent to the server - /// - /// The API client the request is for - /// The uri to send the request to - /// The method of the request - /// Cancellation token - /// The parameters of the request - /// Whether or not the request should be authenticated - /// Where the parameters should be placed, overwrites the value set in the client - /// How array parameters should be serialized, overwrites the value set in the client - /// Credits used for the request - /// The JsonSerializer to use for deserialization - /// Additional headers to send with the request - /// Ignore rate limits for this request - /// - protected virtual async Task> PrepareRequestAsync(RestApiClient apiClient, - Uri uri, - HttpMethod method, - CancellationToken cancellationToken, - Dictionary? parameters = null, - bool signed = false, - HttpMethodParameterPosition? parameterPosition = null, - ArrayParametersSerialization? arraySerialization = null, - int requestWeight = 1, - JsonSerializer? deserializer = null, - Dictionary? additionalHeaders = null, - bool ignoreRatelimit = false) - { - var requestId = NextId(); - - if (signed) - { - var syncTask = apiClient.SyncTimeAsync(); - var timeSyncInfo = apiClient.GetTimeSyncInfo(); - if (timeSyncInfo.TimeSyncState.LastSyncTime == default) - { - // Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue - var syncTimeResult = await syncTask.ConfigureAwait(false); - if (!syncTimeResult) - { - log.Write(LogLevel.Debug, $"[{requestId}] Failed to sync time, aborting request: " + syncTimeResult.Error); - return syncTimeResult.As(default); - } - } - } - - if (!ignoreRatelimit) - { - foreach (var limiter in apiClient.RateLimiters) - { - var limitResult = await limiter.LimitRequestAsync(log, uri.AbsolutePath, method, signed, apiClient.Options.ApiCredentials?.Key, apiClient.Options.RateLimitingBehaviour, requestWeight, cancellationToken).ConfigureAwait(false); - if (!limitResult.Success) - return new CallResult(limitResult.Error!); - } - } - - if (signed && apiClient.AuthenticationProvider == null) - { - log.Write(LogLevel.Warning, $"[{requestId}] Request {uri.AbsolutePath} failed because no ApiCredentials were provided"); - return new CallResult(new NoApiCredentialsError()); - } - - log.Write(LogLevel.Information, $"[{requestId}] Creating request for " + uri); - var paramsPosition = parameterPosition ?? apiClient.ParameterPositions[method]; - var request = ConstructRequest(apiClient, uri, method, parameters, signed, paramsPosition, arraySerialization ?? apiClient.arraySerialization, requestId, additionalHeaders); - - string? paramString = ""; - if (paramsPosition == HttpMethodParameterPosition.InBody) - paramString = $" with request body '{request.Content}'"; - - var headers = request.GetHeaders(); - if (headers.Any()) - paramString += " with headers " + string.Join(", ", headers.Select(h => h.Key + $"=[{string.Join(",", h.Value)}]")); - - apiClient.TotalRequestsMade++; - log.Write(LogLevel.Trace, $"[{requestId}] Sending {method}{(signed ? " signed" : "")} request to {request.Uri}{paramString ?? " "}{(ClientOptions.Proxy == null ? "" : $" via proxy {ClientOptions.Proxy.Host}")}"); - return new CallResult(request); - } - - - - /// - /// Executes the request and returns the result deserialized into the type parameter class - /// - /// The client making the request - /// The request object to execute - /// The JsonSerializer to use for deserialization - /// Cancellation token - /// If an empty response is expected - /// - protected virtual async Task> GetResponseAsync( - BaseApiClient apiClient, - IRequest request, - JsonSerializer? deserializer, - CancellationToken cancellationToken, - bool expectedEmptyResponse) - { - try - { - var sw = Stopwatch.StartNew(); - var response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false); - sw.Stop(); - var statusCode = response.StatusCode; - var headers = response.ResponseHeaders; - var responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - // If we have to manually parse error responses (can't rely on HttpStatusCode) we'll need to read the full - // response before being able to deserialize it into the resulting type since we don't know if it an error response or data - if (apiClient.manualParseError) - { - using var reader = new StreamReader(responseStream); - var data = await reader.ReadToEndAsync().ConfigureAwait(false); - responseStream.Close(); - response.Close(); - log.Write(LogLevel.Debug, $"[{request.RequestId}] Response received in {sw.ElapsedMilliseconds}ms{(log.Level == LogLevel.Trace ? (": "+data): "")}"); - - if (!expectedEmptyResponse) - { - // Validate if it is valid json. Sometimes other data will be returned, 502 error html pages for example - var parseResult = ValidateJson(data); - if (!parseResult.Success) - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!); - - // Let the library implementation see if it is an error response, and if so parse the error - var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false); - if (error != null) - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!); - - // Not an error, so continue deserializing - var deserializeResult = Deserialize(parseResult.Data, deserializer, request.RequestId); - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), deserializeResult.Data, deserializeResult.Error); - } - else - { - if (!string.IsNullOrEmpty(data)) - { - var parseResult = ValidateJson(data); - if (!parseResult.Success) - // Not empty, and not json - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!); - - var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false); - if (error != null) - // Error response - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!); - } - - // Empty success response; okay - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, ClientOptions.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, default); - } - } - else - { - if (expectedEmptyResponse) - { - // We expected an empty response and the request is successful and don't manually parse errors, so assume it's correct - responseStream.Close(); - response.Close(); - - return new WebCallResult(statusCode, headers, sw.Elapsed, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, null); - } - - // Success status code, and we don't have to check for errors. Continue deserializing directly from the stream - var desResult = await DeserializeAsync(responseStream, deserializer, request.RequestId, sw.ElapsedMilliseconds).ConfigureAwait(false); - responseStream.Close(); - response.Close(); - - return new WebCallResult(statusCode, headers, sw.Elapsed, ClientOptions.OutputOriginalData ? desResult.OriginalData : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), desResult.Data, desResult.Error); - } - } - else - { - // Http status code indicates error - using var reader = new StreamReader(responseStream); - var data = await reader.ReadToEndAsync().ConfigureAwait(false); - log.Write(LogLevel.Warning, $"[{request.RequestId}] Error received in {sw.ElapsedMilliseconds}ms: {data}"); - responseStream.Close(); - response.Close(); - var parseResult = ValidateJson(data); - var error = parseResult.Success ? ParseErrorResponse(parseResult.Data) : new ServerError(data)!; - if(error.Code == null || error.Code == 0) - error.Code = (int)response.StatusCode; - return new WebCallResult(statusCode, headers, sw.Elapsed, data, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error); - } - } - catch (HttpRequestException requestException) - { - // Request exception, can't reach server for instance - var exceptionInfo = requestException.ToLogString(); - log.Write(LogLevel.Warning, $"[{request.RequestId}] Request exception: " + exceptionInfo); - return new WebCallResult(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError(exceptionInfo)); - } - catch (OperationCanceledException canceledException) - { - if (cancellationToken != default && canceledException.CancellationToken == cancellationToken) - { - // Cancellation token canceled by caller - log.Write(LogLevel.Warning, $"[{request.RequestId}] Request canceled by cancellation token"); - return new WebCallResult(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new CancellationRequestedError()); - } - else - { - // Request timed out - log.Write(LogLevel.Warning, $"[{request.RequestId}] Request timed out: " + canceledException.ToLogString()); - return new WebCallResult(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError($"[{request.RequestId}] Request timed out")); - } - } - } - - /// - /// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error. - /// When setting manualParseError to true this method will be called for each response to be able to check if the response is an error or not. - /// If the response is an error this method should return the parsed error, else it should return null - /// - /// Received data - /// Null if not an error, Error otherwise - protected virtual Task TryParseErrorAsync(JToken data) - { - return Task.FromResult(null); - } - - /// - /// Creates a request object - /// - /// The API client the request is for - /// The uri to send the request to - /// The method of the request - /// The parameters of the request - /// Whether or not the request should be authenticated - /// Where the parameters should be placed - /// How array parameters should be serialized - /// Unique id of a request - /// Additional headers to send with the request - /// - protected virtual IRequest ConstructRequest( - RestApiClient apiClient, - Uri uri, - HttpMethod method, - Dictionary? parameters, - bool signed, - HttpMethodParameterPosition parameterPosition, - ArrayParametersSerialization arraySerialization, - int requestId, - Dictionary? additionalHeaders) - { - parameters ??= new Dictionary(); - - 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(); - if (apiClient.AuthenticationProvider != null) - apiClient.AuthenticationProvider.AuthenticateRequest( - apiClient, - uri, - method, - parameters, - signed, - arraySerialization, - parameterPosition, - out uriParameters, - out bodyParameters, - out headers); - - // Sanity check - foreach(var param in parameters) - { - if (!uriParameters.ContainsKey(param.Key) && !bodyParameters.ContainsKey(param.Key)) - throw new Exception($"Missing parameter {param.Key} after authentication processing. AuthenticationProvider implementation " + - $"should return provided parameters in either the uri or body parameters output"); - } - - // Add the auth parameters to the uri, start with a new URI to be able to sort the parameters including the auth parameters - uri = uri.SetParameters(uriParameters, arraySerialization); - - var request = RequestFactory.Create(method, uri, requestId); - request.Accept = Constants.JsonContentHeader; - - foreach (var header in headers) - request.AddHeader(header.Key, header.Value); - - if (additionalHeaders != null) - { - foreach (var header in additionalHeaders) - request.AddHeader(header.Key, header.Value); - } - - if (StandardRequestHeaders != null) - { - foreach (var header in StandardRequestHeaders) - // Only add it if it isn't overwritten - if (additionalHeaders?.ContainsKey(header.Key) != true) - request.AddHeader(header.Key, header.Value); - } - - if (parameterPosition == HttpMethodParameterPosition.InBody) - { - var contentType = apiClient.requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader; - if (bodyParameters.Any()) - WriteParamBody(apiClient, request, bodyParameters, contentType); - else - request.SetContent(apiClient.requestBodyEmptyContent, contentType); - } - - return request; - } - - /// - /// Writes the parameters of the request to the request object body - /// - /// The client making the request - /// The request to set the parameters on - /// The parameters to set - /// The content type of the data - protected virtual void WriteParamBody(BaseApiClient apiClient, IRequest request, SortedDictionary parameters, string contentType) - { - if (apiClient.requestBodyFormat == RequestBodyFormat.Json) - { - // Write the parameters as json in the body - var stringData = JsonConvert.SerializeObject(parameters); - request.SetContent(stringData, contentType); - } - else if (apiClient.requestBodyFormat == RequestBodyFormat.FormData) - { - // Write the parameters as form data in the body - var stringData = parameters.ToFormData(); - request.SetContent(stringData, contentType); - } - } - - /// - /// Parse an error response from the server. Only used when server returns a status other than Success(200) - /// - /// The string the request returned - /// - protected virtual Error ParseErrorResponse(JToken error) - { - return new ServerError(error.ToString()); } } } diff --git a/CryptoExchange.Net/Clients/BaseSocketClient.cs b/CryptoExchange.Net/Clients/BaseSocketClient.cs index c4dbb9d..503604a 100644 --- a/CryptoExchange.Net/Clients/BaseSocketClient.cs +++ b/CryptoExchange.Net/Clients/BaseSocketClient.cs @@ -1,17 +1,12 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; namespace CryptoExchange.Net { @@ -31,6 +26,8 @@ namespace CryptoExchange.Net public int CurrentConnections => ApiClients.OfType().Sum(c => c.CurrentConnections); /// public int CurrentSubscriptions => ApiClients.OfType().Sum(s => s.CurrentSubscriptions); + /// + public double IncomingKbps => ApiClients.OfType().Sum(s => s.IncomingKbps); #endregion /// @@ -42,13 +39,6 @@ namespace CryptoExchange.Net { } - /// - public void SetApiCredentials(ApiCredentials credentials) - { - foreach (var apiClient in ApiClients) - apiClient.SetApiCredentials(credentials); - } - /// /// Unsubscribe an update subscription /// diff --git a/CryptoExchange.Net/Clients/RestApiClient.cs b/CryptoExchange.Net/Clients/RestApiClient.cs index 53fa555..12f921f 100644 --- a/CryptoExchange.Net/Clients/RestApiClient.cs +++ b/CryptoExchange.Net/Clients/RestApiClient.cs @@ -1,11 +1,19 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Logging; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Requests; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace CryptoExchange.Net { @@ -14,6 +22,16 @@ namespace CryptoExchange.Net /// public abstract class RestApiClient: BaseApiClient { + /// + /// The factory for creating requests. Used for unit testing + /// + public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); + + /// + /// Request headers to be sent with each request + /// + protected Dictionary? StandardRequestHeaders { get; set; } + /// /// Get time sync info for an API client /// @@ -41,17 +59,455 @@ namespace CryptoExchange.Net /// internal IEnumerable RateLimiters { get; } + /// + /// Options + /// + internal ClientOptions ClientOptions { get; set; } + /// /// ctor /// + /// Logger /// The base client options /// The Api client options - public RestApiClient(BaseRestClientOptions options, RestApiClientOptions apiOptions): base(options, apiOptions) + public RestApiClient(Log log, ClientOptions options, RestApiClientOptions apiOptions): base(log, apiOptions) { var rateLimiters = new List(); foreach (var rateLimiter in apiOptions.RateLimiters) rateLimiters.Add(rateLimiter); RateLimiters = rateLimiters; + ClientOptions = options; + + RequestFactory.Configure(apiOptions.RequestTimeout, options.Proxy, apiOptions.HttpClient); + } + + /// + /// Execute a request to the uri and returns if it was successful + /// + /// The uri to send the request to + /// The method of the request + /// Cancellation token + /// The parameters of the request + /// Whether or not the request should be authenticated + /// Where the parameters should be placed, overwrites the value set in the client + /// How array parameters should be serialized, overwrites the value set in the client + /// Credits used for the request + /// The JsonSerializer to use for deserialization + /// Additional headers to send with the request + /// Ignore rate limits for this request + /// + [return: NotNull] + protected virtual async Task SendRequestAsync( + Uri uri, + HttpMethod method, + CancellationToken cancellationToken, + Dictionary? parameters = null, + bool signed = false, + HttpMethodParameterPosition? parameterPosition = null, + ArrayParametersSerialization? arraySerialization = null, + int requestWeight = 1, + JsonSerializer? deserializer = null, + Dictionary? additionalHeaders = null, + bool ignoreRatelimit = false) + { + var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false); + if (!request) + return new WebCallResult(request.Error!); + + var result = await GetResponseAsync(request.Data, deserializer, cancellationToken, true).ConfigureAwait(false); + return result.AsDataless(); + } + + /// + /// Execute a request to the uri and deserialize the response into the provided type parameter + /// + /// The type to deserialize into + /// The uri to send the request to + /// The method of the request + /// Cancellation token + /// The parameters of the request + /// Whether or not the request should be authenticated + /// Where the parameters should be placed, overwrites the value set in the client + /// How array parameters should be serialized, overwrites the value set in the client + /// Credits used for the request + /// The JsonSerializer to use for deserialization + /// Additional headers to send with the request + /// Ignore rate limits for this request + /// + [return: NotNull] + protected virtual async Task> SendRequestAsync( + Uri uri, + HttpMethod method, + CancellationToken cancellationToken, + Dictionary? parameters = null, + bool signed = false, + HttpMethodParameterPosition? parameterPosition = null, + ArrayParametersSerialization? arraySerialization = null, + int requestWeight = 1, + JsonSerializer? deserializer = null, + Dictionary? additionalHeaders = null, + bool ignoreRatelimit = false + ) where T : class + { + var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false); + if (!request) + return new WebCallResult(request.Error!); + + return await GetResponseAsync(request.Data, deserializer, cancellationToken, false).ConfigureAwait(false); + } + + /// + /// Prepares a request to be sent to the server + /// + /// The uri to send the request to + /// The method of the request + /// Cancellation token + /// The parameters of the request + /// Whether or not the request should be authenticated + /// Where the parameters should be placed, overwrites the value set in the client + /// How array parameters should be serialized, overwrites the value set in the client + /// Credits used for the request + /// The JsonSerializer to use for deserialization + /// Additional headers to send with the request + /// Ignore rate limits for this request + /// + protected virtual async Task> PrepareRequestAsync( + Uri uri, + HttpMethod method, + CancellationToken cancellationToken, + Dictionary? parameters = null, + bool signed = false, + HttpMethodParameterPosition? parameterPosition = null, + ArrayParametersSerialization? arraySerialization = null, + int requestWeight = 1, + JsonSerializer? deserializer = null, + Dictionary? additionalHeaders = null, + bool ignoreRatelimit = false) + { + var requestId = NextId(); + + if (signed) + { + var syncTask = SyncTimeAsync(); + var timeSyncInfo = GetTimeSyncInfo(); + if (timeSyncInfo.TimeSyncState.LastSyncTime == default) + { + // Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue + var syncTimeResult = await syncTask.ConfigureAwait(false); + if (!syncTimeResult) + { + _log.Write(LogLevel.Debug, $"[{requestId}] Failed to sync time, aborting request: " + syncTimeResult.Error); + return syncTimeResult.As(default); + } + } + } + + if (!ignoreRatelimit) + { + foreach (var limiter in RateLimiters) + { + var limitResult = await limiter.LimitRequestAsync(_log, uri.AbsolutePath, method, signed, Options.ApiCredentials?.Key, Options.RateLimitingBehaviour, requestWeight, cancellationToken).ConfigureAwait(false); + if (!limitResult.Success) + return new CallResult(limitResult.Error!); + } + } + + if (signed && AuthenticationProvider == null) + { + _log.Write(LogLevel.Warning, $"[{requestId}] Request {uri.AbsolutePath} failed because no ApiCredentials were provided"); + return new CallResult(new NoApiCredentialsError()); + } + + _log.Write(LogLevel.Information, $"[{requestId}] Creating request for " + uri); + var paramsPosition = parameterPosition ?? ParameterPositions[method]; + var request = ConstructRequest(uri, method, parameters, signed, paramsPosition, arraySerialization ?? this.arraySerialization, requestId, additionalHeaders); + + string? paramString = ""; + if (paramsPosition == HttpMethodParameterPosition.InBody) + paramString = $" with request body '{request.Content}'"; + + var headers = request.GetHeaders(); + if (headers.Any()) + paramString += " with headers " + string.Join(", ", headers.Select(h => h.Key + $"=[{string.Join(",", h.Value)}]")); + + TotalRequestsMade++; + _log.Write(LogLevel.Trace, $"[{requestId}] Sending {method}{(signed ? " signed" : "")} request to {request.Uri}{paramString ?? " "}{(ClientOptions.Proxy == null ? "" : $" via proxy {ClientOptions.Proxy.Host}")}"); + return new CallResult(request); + } + + + + /// + /// Executes the request and returns the result deserialized into the type parameter class + /// + /// The request object to execute + /// The JsonSerializer to use for deserialization + /// Cancellation token + /// If an empty response is expected + /// + protected virtual async Task> GetResponseAsync( + IRequest request, + JsonSerializer? deserializer, + CancellationToken cancellationToken, + bool expectedEmptyResponse) + { + try + { + var sw = Stopwatch.StartNew(); + var response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false); + sw.Stop(); + var statusCode = response.StatusCode; + var headers = response.ResponseHeaders; + var responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + // If we have to manually parse error responses (can't rely on HttpStatusCode) we'll need to read the full + // response before being able to deserialize it into the resulting type since we don't know if it an error response or data + if (manualParseError) + { + using var reader = new StreamReader(responseStream); + var data = await reader.ReadToEndAsync().ConfigureAwait(false); + responseStream.Close(); + response.Close(); + _log.Write(LogLevel.Debug, $"[{request.RequestId}] Response received in {sw.ElapsedMilliseconds}ms{(_log.Level == LogLevel.Trace ? (": " + data) : "")}"); + + if (!expectedEmptyResponse) + { + // Validate if it is valid json. Sometimes other data will be returned, 502 error html pages for example + var parseResult = ValidateJson(data); + if (!parseResult.Success) + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!); + + // Let the library implementation see if it is an error response, and if so parse the error + var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false); + if (error != null) + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!); + + // Not an error, so continue deserializing + var deserializeResult = Deserialize(parseResult.Data, deserializer, request.RequestId); + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), deserializeResult.Data, deserializeResult.Error); + } + else + { + if (!string.IsNullOrEmpty(data)) + { + var parseResult = ValidateJson(data); + if (!parseResult.Success) + // Not empty, and not json + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, parseResult.Error!); + + var error = await TryParseErrorAsync(parseResult.Data).ConfigureAwait(false); + if (error != null) + // Error response + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error!); + } + + // Empty success response; okay + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, Options.OutputOriginalData ? data : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, default); + } + } + else + { + if (expectedEmptyResponse) + { + // We expected an empty response and the request is successful and don't manually parse errors, so assume it's correct + responseStream.Close(); + response.Close(); + + return new WebCallResult(statusCode, headers, sw.Elapsed, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, null); + } + + // Success status code, and we don't have to check for errors. Continue deserializing directly from the stream + var desResult = await DeserializeAsync(responseStream, deserializer, request.RequestId, sw.ElapsedMilliseconds).ConfigureAwait(false); + responseStream.Close(); + response.Close(); + + return new WebCallResult(statusCode, headers, sw.Elapsed, Options.OutputOriginalData ? desResult.OriginalData : null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), desResult.Data, desResult.Error); + } + } + else + { + // Http status code indicates error + using var reader = new StreamReader(responseStream); + var data = await reader.ReadToEndAsync().ConfigureAwait(false); + _log.Write(LogLevel.Warning, $"[{request.RequestId}] Error received in {sw.ElapsedMilliseconds}ms: {data}"); + responseStream.Close(); + response.Close(); + var parseResult = ValidateJson(data); + var error = parseResult.Success ? ParseErrorResponse(parseResult.Data) : new ServerError(data)!; + if (error.Code == null || error.Code == 0) + error.Code = (int)response.StatusCode; + return new WebCallResult(statusCode, headers, sw.Elapsed, data, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, error); + } + } + catch (HttpRequestException requestException) + { + // Request exception, can't reach server for instance + var exceptionInfo = requestException.ToLogString(); + _log.Write(LogLevel.Warning, $"[{request.RequestId}] Request exception: " + exceptionInfo); + return new WebCallResult(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError(exceptionInfo)); + } + catch (OperationCanceledException canceledException) + { + if (cancellationToken != default && canceledException.CancellationToken == cancellationToken) + { + // Cancellation token canceled by caller + _log.Write(LogLevel.Warning, $"[{request.RequestId}] Request canceled by cancellation token"); + return new WebCallResult(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new CancellationRequestedError()); + } + else + { + // Request timed out + _log.Write(LogLevel.Warning, $"[{request.RequestId}] Request timed out: " + canceledException.ToLogString()); + return new WebCallResult(null, null, null, null, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), default, new WebError($"[{request.RequestId}] Request timed out")); + } + } + } + + /// + /// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error. + /// When setting manualParseError to true this method will be called for each response to be able to check if the response is an error or not. + /// If the response is an error this method should return the parsed error, else it should return null + /// + /// Received data + /// Null if not an error, Error otherwise + protected virtual Task TryParseErrorAsync(JToken data) + { + return Task.FromResult(null); + } + + /// + /// Creates a request object + /// + /// The uri to send the request to + /// The method of the request + /// The parameters of the request + /// Whether or not the request should be authenticated + /// Where the parameters should be placed + /// How array parameters should be serialized + /// Unique id of a request + /// Additional headers to send with the request + /// + protected virtual IRequest ConstructRequest( + Uri uri, + HttpMethod method, + Dictionary? parameters, + bool signed, + HttpMethodParameterPosition parameterPosition, + ArrayParametersSerialization arraySerialization, + int requestId, + Dictionary? additionalHeaders) + { + parameters ??= new Dictionary(); + + 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(); + if (AuthenticationProvider != null) + { + AuthenticationProvider.AuthenticateRequest( + this, + uri, + method, + parameters, + signed, + arraySerialization, + parameterPosition, + out uriParameters, + out bodyParameters, + out headers); + } + + // Sanity check + foreach (var param in parameters) + { + if (!uriParameters.ContainsKey(param.Key) && !bodyParameters.ContainsKey(param.Key)) + { + throw new Exception($"Missing parameter {param.Key} after authentication processing. AuthenticationProvider implementation " + + $"should return provided parameters in either the uri or body parameters output"); + } + } + + // Add the auth parameters to the uri, start with a new URI to be able to sort the parameters including the auth parameters + uri = uri.SetParameters(uriParameters, arraySerialization); + + var request = RequestFactory.Create(method, uri, requestId); + request.Accept = Constants.JsonContentHeader; + + foreach (var header in headers) + request.AddHeader(header.Key, header.Value); + + if (additionalHeaders != null) + { + foreach (var header in additionalHeaders) + request.AddHeader(header.Key, header.Value); + } + + if (StandardRequestHeaders != null) + { + foreach (var header in StandardRequestHeaders) + { + // Only add it if it isn't overwritten + if (additionalHeaders?.ContainsKey(header.Key) != true) + request.AddHeader(header.Key, header.Value); + } + } + + if (parameterPosition == HttpMethodParameterPosition.InBody) + { + var contentType = requestBodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader; + if (bodyParameters.Any()) + WriteParamBody(request, bodyParameters, contentType); + else + request.SetContent(requestBodyEmptyContent, contentType); + } + + return request; + } + + /// + /// Writes the parameters of the request to the request object body + /// + /// 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) + { + if (requestBodyFormat == RequestBodyFormat.Json) + { + // Write the parameters as json in the body + var stringData = JsonConvert.SerializeObject(parameters); + request.SetContent(stringData, contentType); + } + else if (requestBodyFormat == RequestBodyFormat.FormData) + { + // Write the parameters as form data in the body + var stringData = parameters.ToFormData(); + request.SetContent(stringData, contentType); + } + } + + /// + /// Parse an error response from the server. Only used when server returns a status other than Success(200) + /// + /// The string the request returned + /// + protected virtual Error ParseErrorResponse(JToken error) + { + return new ServerError(error.ToString()); } /// diff --git a/CryptoExchange.Net/Clients/SocketApiClient.cs b/CryptoExchange.Net/Clients/SocketApiClient.cs index e081122..522c5c1 100644 --- a/CryptoExchange.Net/Clients/SocketApiClient.cs +++ b/CryptoExchange.Net/Clients/SocketApiClient.cs @@ -105,15 +105,21 @@ namespace CryptoExchange.Net /// public new SocketApiClientOptions Options => (SocketApiClientOptions)base.Options; + /// + /// Options + /// + internal ClientOptions ClientOptions { get; set; } #endregion /// /// ctor /// /// log + /// Client options /// The Api client options - public SocketApiClient(Log log, SocketApiClientOptions apiOptions): base(log, apiOptions) + public SocketApiClient(Log log, ClientOptions options, SocketApiClientOptions apiOptions): base(log, apiOptions) { + ClientOptions = options; } /// @@ -131,23 +137,21 @@ namespace CryptoExchange.Net /// Connect to an url and listen for data on the BaseAddress /// /// The type of the expected data - /// The API client the subscription is for /// The optional request object to send, will be serialized to json /// The identifier to use, necessary if no request object is sent /// If the subscription is to an authenticated endpoint /// The handler of update data /// Cancellation token for closing this subscription /// - protected virtual Task> SubscribeAsync(SocketApiClient apiClient, object? request, string? identifier, bool authenticated, Action> dataHandler, CancellationToken ct) + protected virtual Task> SubscribeAsync(object? request, string? identifier, bool authenticated, Action> dataHandler, CancellationToken ct) { - return SubscribeAsync(apiClient, Options.BaseAddress, request, identifier, authenticated, dataHandler, ct); + return SubscribeAsync(Options.BaseAddress, request, identifier, authenticated, dataHandler, ct); } /// /// Connect to an url and listen for data /// /// The type of the expected data - /// The API client the subscription is for /// The URL to connect to /// The optional request object to send, will be serialized to json /// The identifier to use, necessary if no request object is sent @@ -155,7 +159,7 @@ namespace CryptoExchange.Net /// The handler of update data /// Cancellation token for closing this subscription /// - protected virtual async Task> SubscribeAsync(SocketApiClient apiClient, string url, object? request, string? identifier, bool authenticated, Action> dataHandler, CancellationToken ct) + protected virtual async Task> SubscribeAsync(string url, object? request, string? identifier, bool authenticated, Action> dataHandler, CancellationToken ct) { if (_disposing) return new CallResult(new InvalidOperationError("Client disposed, can't subscribe")); @@ -179,7 +183,7 @@ namespace CryptoExchange.Net while (true) { // Get a new or existing socket connection - var socketResult = await GetSocketConnection(apiClient, url, authenticated).ConfigureAwait(false); + var socketResult = await GetSocketConnection(url, authenticated).ConfigureAwait(false); if (!socketResult) return socketResult.As(null); @@ -519,11 +523,10 @@ namespace CryptoExchange.Net /// /// Get the url to connect to (defaults to BaseAddress form the client options) /// - /// /// /// /// - protected virtual Task> GetConnectionUrlAsync(SocketApiClient apiClient, string address, bool authentication) + protected virtual Task> GetConnectionUrlAsync(string address, bool authentication) { return Task.FromResult(new CallResult(address)); } @@ -531,10 +534,9 @@ namespace CryptoExchange.Net /// /// Get the url to reconnect to after losing a connection /// - /// /// /// - public virtual Task GetReconnectUriAsync(SocketApiClient apiClient, SocketConnection connection) + public virtual Task GetReconnectUriAsync(SocketConnection connection) { return Task.FromResult(connection.ConnectionUri); } @@ -542,15 +544,14 @@ namespace CryptoExchange.Net /// /// Gets a connection for a new subscription or query. Can be an existing if there are open position or a new one. /// - /// The API client the connection is for /// The address the socket is for /// Whether the socket should be authenticated /// - protected virtual async Task> GetSocketConnection(SocketApiClient apiClient, string address, bool authenticated) + protected virtual async Task> GetSocketConnection(string address, bool authenticated) { var socketResult = socketConnections.Where(s => (s.Value.Status == SocketConnection.SocketStatus.None || s.Value.Status == SocketConnection.SocketStatus.Connected) && s.Value.Tag.TrimEnd('/') == address.TrimEnd('/') - && (s.Value.ApiClient.GetType() == apiClient.GetType()) + && (s.Value.ApiClient.GetType() == GetType()) && (s.Value.Authenticated == authenticated || !authenticated) && s.Value.Connected).OrderBy(s => s.Value.SubscriptionCount).FirstOrDefault(); var result = socketResult.Equals(default(KeyValuePair)) ? null : socketResult.Value; if (result != null) @@ -562,7 +563,7 @@ namespace CryptoExchange.Net } } - var connectionAddress = await GetConnectionUrlAsync(apiClient, address, authenticated).ConfigureAwait(false); + var connectionAddress = await GetConnectionUrlAsync(address, authenticated).ConfigureAwait(false); if (!connectionAddress) { _log.Write(LogLevel.Warning, $"Failed to determine connection url: " + connectionAddress.Error); @@ -574,7 +575,7 @@ namespace CryptoExchange.Net // Create new socket var socket = CreateSocket(connectionAddress.Data!); - var socketConnection = new SocketConnection(_log, apiClient, socket, address); + var socketConnection = new SocketConnection(_log, this, socket, address); socketConnection.UnhandledMessage += HandleUnhandledMessage; foreach (var kvp in genericHandlers) { @@ -623,7 +624,7 @@ namespace CryptoExchange.Net KeepAliveInterval = KeepAliveInterval, ReconnectInterval = Options.ReconnectInterval, RatelimitPerSecond = RateLimitPerSocketPerSecond, - Proxy = Options.Proxy, + Proxy = ClientOptions.Proxy, Timeout = Options.SocketNoDataTimeout }; diff --git a/CryptoExchange.Net/Interfaces/IRestClient.cs b/CryptoExchange.Net/Interfaces/IRestClient.cs index 30e9604..b5ad0ef 100644 --- a/CryptoExchange.Net/Interfaces/IRestClient.cs +++ b/CryptoExchange.Net/Interfaces/IRestClient.cs @@ -10,20 +10,15 @@ namespace CryptoExchange.Net.Interfaces public interface IRestClient: IDisposable { /// - /// The factory for creating requests. Used for unit testing + /// The options provided for this client /// - IRequestFactory RequestFactory { get; set; } + ClientOptions ClientOptions { get; } /// /// The total amount of requests made with this client /// int TotalRequestsMade { get; } - /// - /// The options provided for this client - /// - BaseRestClientOptions ClientOptions { get; } - /// /// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options. /// diff --git a/CryptoExchange.Net/Interfaces/ISocketClient.cs b/CryptoExchange.Net/Interfaces/ISocketClient.cs index f441093..54d736e 100644 --- a/CryptoExchange.Net/Interfaces/ISocketClient.cs +++ b/CryptoExchange.Net/Interfaces/ISocketClient.cs @@ -14,7 +14,7 @@ namespace CryptoExchange.Net.Interfaces /// /// The options provided for this client /// - BaseSocketClientOptions ClientOptions { get; } + ClientOptions ClientOptions { get; } /// /// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.